From dea08a3a4115dc14ffb499419daedcfa809ddfec Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Mon, 20 Oct 2025 17:51:01 +0200 Subject: [PATCH] Common api - Group by query (#15108) closes https://github.com/twentyhq/core-team-issues/issues/1626 --- ...common-create-many-query-runner.service.ts | 56 ++- .../common-find-many-query-runner.service.ts | 36 +- .../common-find-one-query-runner.service.ts | 32 +- .../common-group-by-query-runner.service.ts | 327 ++++++++++++++++++ .../common-query-runners.ts | 2 + .../api/common/core-common-api.module.ts | 6 + .../types/common-group-by-output-item.type.ts | 9 + .../common/types/common-query-args.type.ts | 30 +- .../common-selected-fields-result.type.ts | 8 +- ...graphql-query-group-by-resolver.service.ts | 51 +-- ...ute-is-numeric-returning-aggregate.util.ts | 36 ++ ...ult-with-group-by-dimension-values.util.ts | 59 ++-- .../factories/query-runner-args.factory.ts | 10 +- .../types/workspace-query-hook.type.ts | 5 +- .../factories/create-many-resolver.factory.ts | 14 +- .../factories/create-one-resolver.factory.ts | 14 +- .../factories/find-many-resolver.factory.ts | 33 +- .../factories/find-one-resolver.factory.ts | 14 +- .../factories/group-by-resolver.factory.ts | 29 ++ .../workspace-resolvers-builder.interface.ts | 1 + .../controllers/rest-api-core.controller.ts | 14 + .../handlers/rest-api-create-many.handler.ts | 5 +- .../handlers/rest-api-create-one.handler.ts | 4 +- .../handlers/rest-api-find-many.handler.ts | 4 +- .../handlers/rest-api-find-one.handler.ts | 4 +- .../handlers/rest-api-group-by.handler.ts | 76 ++++ .../core/interfaces/rest-api-base.handler.ts | 5 +- .../path-parsers/parse-core-path.utils.ts | 2 +- .../api/rest/core/rest-api-core.module.ts | 2 + .../selected-fields-handler.ts | 72 ++-- .../core/services/rest-api-core.service.ts | 16 +- ...aggregate-fields-rest-request.util.spec.ts | 77 +++++ ...arse-aggregate-fields-rest-request.util.ts | 37 ++ .../parse-depth-rest-request.util.spec.ts | 46 +++ .../parse-depth-rest-request.util.ts | 9 +- .../check-filter-query.util.ts | 8 +- ...filter-without-metadata-validation.util.ts | 7 +- .../parse-group-by-rest-request.util.spec.ts | 66 ++++ .../parse-group-by-rest-request.util.ts | 29 ++ .../parse-limit-rest-request.util.ts | 9 +- ...arse-omit-null-values-rest-request.util.ts | 13 + .../parse-order-by-rest-request.util.ts | 9 +- ...by-with-group-by-rest-request.util.spec.ts | 50 +++ ...rder-by-with-group-by-rest-request.util.ts | 23 ++ .../rest-input-request-parser.exception.ts | 13 + .../parse-view-id-rest-request.util.spec.ts | 19 + .../parse-view-id-rest-request.util.ts | 15 + ...-runner-rest-api-exception-handler.util.ts | 5 + .../group-by-resolvers.integration-spec.ts | 66 ++++ 49 files changed, 1223 insertions(+), 254 deletions(-) create mode 100644 packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts create mode 100644 packages/twenty-server/src/engine/api/common/types/common-group-by-output-item.type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util.ts create mode 100644 packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/__tests__/parse-depth-rest-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/__tests__/parse-group-by-rest-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/__tests__/parse-order-by-with-group-by-rest-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/__tests__/parse-view-id-rest-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util.ts diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts index 16f06cebc72..e6573b8b4be 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service.ts @@ -20,7 +20,9 @@ import { CommonQueryNames, CreateManyQueryArgs, } from 'src/engine/api/common/types/common-query-args.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 { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util'; @@ -37,7 +39,7 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerService { async run({ args, - authContext: toValidateAuthContext, + authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, }: { @@ -46,8 +48,6 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer objectMetadataMaps: ObjectMetadataMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; }): Promise { - const authContext = toValidateAuthContext; - if (!isWorkspaceAuthContext(authContext)) { throw new CommonQueryRunnerException( 'Invalid auth context', @@ -63,6 +63,18 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer } }); + //TODO : Refacto-common - QueryParser should be common branded service + const commonQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ); + + const selectedFieldsResult = commonQueryParser.parseSelectedFields( + objectMetadataItemWithFieldMaps, + args.selectedFields, + objectMetadataMaps, + ); + const { workspaceDataSource, repository, rolePermissionConfig } = await this.prepareQueryRunnerContext({ authContext, @@ -80,24 +92,25 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer objectMetadataItemWithFieldMaps, objectMetadataMaps, args: processedArgs, + selectedFieldsResult, }); const upsertedRecords = await this.fetchUpsertedRecords({ - args: processedArgs, objectRecords, objectMetadataItemWithFieldMaps, objectMetadataMaps, repository, + selectedFieldsResult, }); await this.processNestedRelationsIfNeeded({ - args: processedArgs, records: upsertedRecords, objectMetadataItemWithFieldMaps, objectMetadataMaps, rolePermissionConfig, authContext, workspaceDataSource, + selectedFieldsResult, }); return upsertedRecords; @@ -136,16 +149,18 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer objectMetadataItemWithFieldMaps, objectMetadataMaps, args, + selectedFieldsResult, }: { repository: WorkspaceRepository; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataMaps: ObjectMetadataMaps; args: CreateManyQueryArgs; + selectedFieldsResult: CommonSelectedFieldsResult; }): Promise { if (!args.upsert) { const selectedColumns = buildColumnsToReturn({ - select: args.selectedFieldsResult.select, - relations: args.selectedFieldsResult.relations, + select: selectedFieldsResult.select, + relations: selectedFieldsResult.relations, objectMetadataItemWithFieldMaps, objectMetadataMaps, }); @@ -158,6 +173,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer objectMetadataItemWithFieldMaps, objectMetadataMaps, args, + selectedFieldsResult, }); } @@ -166,11 +182,13 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer objectMetadataItemWithFieldMaps, objectMetadataMaps, args, + selectedFieldsResult, }: { repository: WorkspaceRepository; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataMaps: ObjectMetadataMaps; args: CreateManyQueryArgs; + selectedFieldsResult: CommonSelectedFieldsResult; }): Promise { const conflictingFields = getConflictingFields( objectMetadataItemWithFieldMaps, @@ -195,8 +213,8 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer }; const columnsToReturn = buildColumnsToReturn({ - select: args.selectedFieldsResult.select, - relations: args.selectedFieldsResult.relations, + select: selectedFieldsResult.select, + relations: selectedFieldsResult.relations, objectMetadataItemWithFieldMaps, objectMetadataMaps, }); @@ -325,25 +343,25 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer } private async fetchUpsertedRecords({ - args, objectRecords, objectMetadataItemWithFieldMaps, objectMetadataMaps, repository, + selectedFieldsResult, }: { - args: CreateManyQueryArgs; objectRecords: InsertResult; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataMaps: ObjectMetadataMaps; repository: WorkspaceRepository; + selectedFieldsResult: CommonSelectedFieldsResult; }): Promise { const queryBuilder = repository.createQueryBuilder( objectMetadataItemWithFieldMaps.nameSingular, ); const columnsToSelect = buildColumnsToSelect({ - select: args.selectedFieldsResult.select, - relations: args.selectedFieldsResult.relations, + select: selectedFieldsResult.select, + relations: selectedFieldsResult.relations, objectMetadataItemWithFieldMaps, objectMetadataMaps, }); @@ -363,23 +381,23 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer } private async processNestedRelationsIfNeeded({ - args, records, objectMetadataItemWithFieldMaps, objectMetadataMaps, rolePermissionConfig, authContext, workspaceDataSource, + selectedFieldsResult, }: { - args: CreateManyQueryArgs; records: ObjectRecord[]; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; objectMetadataMaps: ObjectMetadataMaps; - rolePermissionConfig?: RolePermissionConfig; authContext: AuthContext; workspaceDataSource: WorkspaceDataSource; + rolePermissionConfig?: RolePermissionConfig; + selectedFieldsResult: CommonSelectedFieldsResult; }): Promise { - if (!args.selectedFieldsResult.relations) { + if (!selectedFieldsResult.relations) { return; } @@ -388,7 +406,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectRecords: records, //TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common - relations: args.selectedFieldsResult.relations as Record< + relations: selectedFieldsResult.relations as Record< string, FindOptionsRelations >, @@ -396,7 +414,7 @@ export class CommonCreateManyQueryRunnerService extends CommonBaseQueryRunnerSer authContext, workspaceDataSource, rolePermissionConfig, - selectedFields: args.selectedFieldsResult.select, + selectedFields: selectedFieldsResult.select, }); } 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 1b5b48d2765..18e3274f1ef 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 @@ -21,6 +21,7 @@ import { CommonQueryNames, FindManyQueryArgs, } from 'src/engine/api/common/types/common-query-args.type'; +import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { getPageInfo } from 'src/engine/api/common/utils/get-page-info.util'; import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; @@ -36,7 +37,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerService { async run({ args, - authContext: toValidateAuthContext, + authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, }: { @@ -49,9 +50,9 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi aggregatedValues: Record; totalCount: number; pageInfo: CommonPageInfo; + selectedFieldsResult: CommonSelectedFieldsResult; }> { this.validate(args); - const authContext = toValidateAuthContext; if (!isWorkspaceAuthContext(authContext)) { throw new CommonQueryRunnerException( @@ -66,6 +67,17 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi objectMetadataItemWithFieldMaps, }); + const commonQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ); + + const selectedFieldsResult = commonQueryParser.parseSelectedFields( + objectMetadataItemWithFieldMaps, + args.selectedFields, + objectMetadataMaps, + ); + const processedArgs = await this.processQueryArgs({ authContext, objectMetadataItemWithFieldMaps, @@ -80,11 +92,6 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi let appliedFilters = processedArgs.filter ?? ({} as ObjectRecordFilter); - const commonQueryParser = new GraphqlQueryParser( - objectMetadataItemWithFieldMaps, - objectMetadataMaps, - ); - commonQueryParser.applyFilterToBuilder( aggregateQueryBuilder, objectMetadataItemWithFieldMaps.nameSingular, @@ -136,7 +143,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters); ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ - selectedAggregatedFields: processedArgs.selectedFieldsResult.aggregate, + selectedAggregatedFields: selectedFieldsResult.aggregate, queryBuilder: aggregateQueryBuilder, objectMetadataNameSingular: objectMetadataItemWithFieldMaps.nameSingular, }); @@ -145,8 +152,8 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi processedArgs.first ?? processedArgs.last ?? QUERY_MAX_RECORDS; const columnsToSelect = buildColumnsToSelect({ - select: processedArgs.selectedFieldsResult.select, - relations: processedArgs.selectedFieldsResult.relations, + select: selectedFieldsResult.select, + relations: selectedFieldsResult.relations, objectMetadataItemWithFieldMaps, objectMetadataMaps, }); @@ -176,23 +183,23 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi const parentObjectRecordsAggregatedValues = await aggregateQueryBuilder.getRawOne(); - if (processedArgs.selectedFieldsResult.relations) { + if (selectedFieldsResult.relations) { await this.processNestedRelationsHelper.processNestedRelations({ objectMetadataMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectRecords: objectRecords, parentObjectRecordsAggregatedValues, //TODO : Refacto-common - Typing to fix when switching processNestedRelationsHelper to Common - relations: processedArgs.selectedFieldsResult.relations as Record< + relations: selectedFieldsResult.relations as Record< string, FindOptionsRelations >, - aggregate: processedArgs.selectedFieldsResult.aggregate, + aggregate: selectedFieldsResult.aggregate, limit: QUERY_MAX_RECORDS, authContext, workspaceDataSource, rolePermissionConfig, - selectedFields: processedArgs.selectedFieldsResult.select, + selectedFields: selectedFieldsResult.select, }); } @@ -209,6 +216,7 @@ export class CommonFindManyQueryRunnerService extends CommonBaseQueryRunnerServi aggregatedValues: parentObjectRecordsAggregatedValues, totalCount: parentObjectRecordsAggregatedValues?.totalCount, pageInfo, + selectedFieldsResult, }; } 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 ba64973c39c..0a57755a489 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 @@ -28,7 +28,7 @@ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-met export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerService { async run({ args, - authContext: toValidateAuthContext, + authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, }: { @@ -37,8 +37,6 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic objectMetadataMaps: ObjectMetadataMaps; objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; }): Promise { - const authContext = toValidateAuthContext; - if (!isWorkspaceAuthContext(authContext)) { throw new CommonQueryRunnerException( 'Invalid auth context', @@ -52,6 +50,18 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic objectMetadataItemWithFieldMaps, }); + //TODO : Refacto-common - QueryParser should be common branded service + const commonQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ); + + const selectedFieldsResult = commonQueryParser.parseSelectedFields( + objectMetadataItemWithFieldMaps, + args.selectedFields, + objectMetadataMaps, + ); + const processedArgs = await this.processQueryArgs({ authContext, objectMetadataItemWithFieldMaps, @@ -72,12 +82,6 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic objectMetadataItemWithFieldMaps.nameSingular, ); - //TODO : Refacto-common - QueryParser should be common branded service - const commonQueryParser = new GraphqlQueryParser( - objectMetadataItemWithFieldMaps, - objectMetadataMaps, - ); - commonQueryParser.applyFilterToBuilder( queryBuilder, objectMetadataItemWithFieldMaps.nameSingular, @@ -90,8 +94,8 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic ); const columnsToSelect = buildColumnsToSelect({ - select: args.selectedFieldsResult.select, - relations: args.selectedFieldsResult.relations, + select: selectedFieldsResult.select, + relations: selectedFieldsResult.relations, objectMetadataItemWithFieldMaps, objectMetadataMaps, }); @@ -111,13 +115,13 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic const objectRecords = [objectRecord] as ObjectRecord[]; - if (isDefined(args.selectedFieldsResult.relations)) { + if (isDefined(selectedFieldsResult.relations)) { await this.processNestedRelationsHelper.processNestedRelations({ objectMetadataMaps, parentObjectMetadataItem: objectMetadataItemWithFieldMaps, parentObjectRecords: objectRecords, //TODO : Refacto-common - To fix when switching processNestedRelationsHelper to Common - relations: args.selectedFieldsResult.relations as Record< + relations: selectedFieldsResult.relations as Record< string, FindOptionsRelations >, @@ -125,7 +129,7 @@ export class CommonFindOneQueryRunnerService extends CommonBaseQueryRunnerServic authContext, workspaceDataSource, rolePermissionConfig, - selectedFields: args.selectedFieldsResult.select, + selectedFields: selectedFieldsResult.select, }); } 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 new file mode 100644 index 00000000000..4215f04260c --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -0,0 +1,327 @@ +import { Injectable } from '@nestjs/common'; + +import { + CompositeFieldSubFieldName, + PartialFieldMetadataItemOption, + RecordFilterGroupLogicalOperator, +} from 'twenty-shared/types'; +import { + assertIsDefinedOrThrow, + combineFilters, + computeRecordGqlOperationFilter, + convertViewFilterValueToString, + getFilterTypeFromFieldType, + turnAnyFieldFilterIntoRecordGqlFilter, +} from 'twenty-shared/utils'; + +import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; +import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { CommonBaseQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-base-query-runner.service'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; +import { + CommonQueryNames, + GroupByQueryArgs, +} from 'src/engine/api/common/types/common-query-args.type'; +import { isWorkspaceAuthContext } from 'src/engine/api/common/utils/is-workspace-auth-context.util'; +import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; +import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util'; +import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; +import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; +import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; +import { parseGroupByArgs } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util'; +import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; +import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; +import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; +import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service'; +import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/services/view-filter.service'; +import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; +import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; +import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; + +@Injectable() +export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerService { + constructor( + private readonly viewFilterService: ViewFilterService, + private readonly viewFilterGroupService: ViewFilterGroupService, + private readonly viewService: ViewService, + ) { + super(); + } + + async run({ + args, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }: { + args: GroupByQueryArgs; + authContext: AuthContext; + objectMetadataMaps: ObjectMetadataMaps; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + }): Promise { + if (!isWorkspaceAuthContext(authContext)) { + throw new CommonQueryRunnerException( + 'Invalid auth context', + CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT, + ); + } + + const { repository } = await this.prepareQueryRunnerContext({ + authContext, + objectMetadataItemWithFieldMaps, + }); + + //TODO : Refacto-common - QueryParser should be common branded service + const commonQueryParser = new GraphqlQueryParser( + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ); + + const selectedFieldsResult = commonQueryParser.parseSelectedFields( + objectMetadataItemWithFieldMaps, + args.selectedFields, + objectMetadataMaps, + ); + + const processedArgs = await this.processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }); + + const objectMetadataNameSingular = + objectMetadataItemWithFieldMaps.nameSingular; + + let queryBuilder = repository.createQueryBuilder( + objectMetadataNameSingular, + ); + + let appliedFilters = args.filter ?? ({} as ObjectRecordFilter); + + if (args.viewId) { + appliedFilters = await this.addFiltersFromView({ + args: processedArgs, + authContext, + objectMetadataItemWithFieldMaps, + appliedFilters, + }); + } + + commonQueryParser.applyFilterToBuilder( + queryBuilder, + objectMetadataNameSingular, + appliedFilters, + ); + + commonQueryParser.applyDeletedAtToBuilder(queryBuilder, appliedFilters); + + ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({ + selectedAggregatedFields: selectedFieldsResult.aggregate, + queryBuilder, + objectMetadataNameSingular, + }); + + const groupByFields = parseGroupByArgs( + processedArgs, + objectMetadataItemWithFieldMaps, + ); + + const groupByDefinitions = groupByFields.map((groupByField) => { + const columnNameWithQuotes = `"${ + formatColumnNamesFromCompositeFieldAndSubfields( + groupByField.fieldMetadata.name, + groupByField.subFieldName ? [groupByField.subFieldName] : undefined, + )[0] + }"`; + const alias = + removeQuotes(columnNameWithQuotes) + + (isGroupByDateField(groupByField) + ? `_${groupByField.dateGranularity}` + : ''); + + return { + columnNameWithQuotes, + expression: getGroupByExpression({ + groupByField, + columnNameWithQuotes, + }), + alias, + dateGranularity: isGroupByDateField(groupByField) + ? groupByField.dateGranularity + : undefined, + }; + }); + + groupByDefinitions.forEach((groupByColumn, index) => { + queryBuilder.addSelect(groupByColumn.expression, groupByColumn.alias); + + if (index === 0) { + queryBuilder.groupBy(groupByColumn.expression); + } else { + queryBuilder.addGroupBy(groupByColumn.expression); + } + }); + + if (processedArgs.omitNullValues) { + const aggregateFields = selectedFieldsResult.aggregate ?? {}; + + Object.values(aggregateFields).forEach((aggregationField) => { + const aggregateExpression = + ProcessAggregateHelper.getAggregateExpression( + aggregationField, + objectMetadataNameSingular, + ); + + if (aggregateExpression) { + queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); + + const isNumericReturningAggregate = + computeIsNumericReturningAggregate( + aggregationField.aggregateOperation, + aggregationField.fromFieldType, + ); + + if (isNumericReturningAggregate) { + queryBuilder.andHaving(`${aggregateExpression} != 0`); + } + } + }); + } + + commonQueryParser.applyGroupByOrderToBuilder( + queryBuilder, + processedArgs.orderBy ?? [], + groupByFields, + ); + + const result = await queryBuilder.getRawMany(); + + return formatResultWithGroupByDimensionValues( + result, + groupByDefinitions, + Object.keys(selectedFieldsResult.aggregate), + ); + } + + private async addFiltersFromView({ + args, + objectMetadataItemWithFieldMaps, + appliedFilters, + authContext, + }: { + args: GroupByQueryArgs; + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + appliedFilters: ObjectRecordFilter; + }): Promise { + assertIsDefinedOrThrow(args.viewId); + + const workspaceId = authContext.workspace.id; + + const viewFilters = await this.viewFilterService.findByViewId( + workspaceId, + args.viewId, + ); + + const viewFilterGroups = await this.viewFilterGroupService.findByViewId( + workspaceId, + args.viewId, + ); + + const recordFilters = viewFilters.map((viewFilter) => { + const fieldMetadataItem = + objectMetadataItemWithFieldMaps.fieldsById[viewFilter.fieldMetadataId]; + + return { + id: viewFilter.id, + fieldMetadataId: viewFilter.fieldMetadataId, + value: convertViewFilterValueToString(viewFilter.value), + type: getFilterTypeFromFieldType(fieldMetadataItem.type), + operand: viewFilter.operand, + recordFilterGroupId: viewFilter.viewFilterGroupId, + positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup, + subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName, + }; + }); + + const recordFilterGroups = viewFilterGroups.map((viewFilterGroup) => { + return { + id: viewFilterGroup.id, + logicalOperator: + viewFilterGroup.logicalOperator as unknown as RecordFilterGroupLogicalOperator, // TODO - https://github.com/twentyhq/twenty/issues/14746 + parentRecordFilterGroupId: viewFilterGroup.parentViewFilterGroupId, + }; + }); + + const fields = Object.values( + objectMetadataItemWithFieldMaps.fieldsById, + ).map((field) => ({ + id: field.id, + name: field.name, + type: field.type, + label: field.label, + options: field.options as PartialFieldMetadataItemOption[], + })); + + const filtersFromView = computeRecordGqlOperationFilter({ + recordFilters, + recordFilterGroups: recordFilterGroups, + fields, + filterValueDependencies: {}, + }); + + let view: ViewEntity | null = viewFilters[0]?.view; + + if (!view) { + view = await this.viewService.findById(args.viewId, workspaceId); + } + + const { recordGqlOperationFilter: anyFieldFilter } = + turnAnyFieldFilterIntoRecordGqlFilter({ + fields, + filterValue: view?.anyFieldFilterValue ?? '', + }); + + appliedFilters = combineFilters([ + appliedFilters, + filtersFromView, + anyFieldFilter, + ]); + + return appliedFilters; + } + + async processQueryArgs({ + authContext, + objectMetadataItemWithFieldMaps, + args, + }: { + authContext: WorkspaceAuthContext; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + args: GroupByQueryArgs; + }): Promise { + const hookedArgs = + (await this.workspaceQueryHookService.executePreQueryHooks( + authContext, + objectMetadataItemWithFieldMaps.nameSingular, + CommonQueryNames.groupBy, + args, + //TODO : Refacto-common - To fix when updating workspaceQueryHookService, removing gql typing dependency + )) as GroupByQueryArgs; + + return { + ...hookedArgs, + filter: this.queryRunnerArgsFactory.overrideFilterByFieldMetadata( + hookedArgs.filter, + objectMetadataItemWithFieldMaps, + ), + }; + } +} diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts index 77cc879f0ea..684394a8332 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-query-runners.ts @@ -2,10 +2,12 @@ import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; +import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service'; export const CommonQueryRunners = [ CommonFindOneQueryRunnerService, CommonFindManyQueryRunnerService, CommonCreateOneQueryRunnerService, CommonCreateManyQueryRunnerService, + CommonGroupByQueryRunnerService, ]; diff --git a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts index 981aa30a2b5..75097824a78 100644 --- a/packages/twenty-server/src/engine/api/common/core-common-api.module.ts +++ b/packages/twenty-server/src/engine/api/common/core-common-api.module.ts @@ -14,6 +14,9 @@ import { FileModule } from 'src/engine/core-modules/file/file.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; +import { ViewFilterGroupModule } from 'src/engine/metadata-modules/view-filter-group/view-filter-group.module'; +import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module'; +import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module'; @Module({ @@ -26,6 +29,9 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor ApiKeyModule, WorkspacePermissionsCacheModule, FileModule, + ViewModule, + ViewFilterModule, + ViewFilterGroupModule, ], providers: [ ProcessNestedRelationsHelper, diff --git a/packages/twenty-server/src/engine/api/common/types/common-group-by-output-item.type.ts b/packages/twenty-server/src/engine/api/common/types/common-group-by-output-item.type.ts new file mode 100644 index 00000000000..ba2c8d8e70e --- /dev/null +++ b/packages/twenty-server/src/engine/api/common/types/common-group-by-output-item.type.ts @@ -0,0 +1,9 @@ +type AggregateValues = { + [key: string]: string; +}; + +type GroupByDimensionValues = { + groupByDimensionValues: string[]; +}; + +export type CommonGroupByOutputItem = AggregateValues & GroupByDimensionValues; diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts index b298401e3a8..ee1090b31c9 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -1,25 +1,30 @@ -import { type ObjectRecord } from 'twenty-shared/types'; +import { + type ObjectRecord, + type OrderByWithGroupBy, +} from 'twenty-shared/types'; import { type ObjectRecordFilter, + type ObjectRecordGroupBy, type ObjectRecordOrderBy, } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; -import { type CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; export enum CommonQueryNames { findOne = 'findOne', findMany = 'findMany', createMany = 'createMany', + groupBy = 'groupBy', } export interface FindOneQueryArgs { - selectedFieldsResult: CommonSelectedFieldsResult; + selectedFields: CommonSelectedFields; filter?: ObjectRecordFilter; } export interface FindManyQueryArgs { - selectedFieldsResult: CommonSelectedFieldsResult; + selectedFields: CommonSelectedFields; filter?: ObjectRecordFilter; orderBy?: ObjectRecordOrderBy; first?: number; @@ -29,13 +34,26 @@ export interface FindManyQueryArgs { } export interface CreateManyQueryArgs { - selectedFieldsResult: CommonSelectedFieldsResult; + selectedFields: CommonSelectedFields; data: Partial[]; upsert?: boolean; } export interface CreateOneQueryArgs { - selectedFieldsResult: CommonSelectedFieldsResult; + selectedFields: CommonSelectedFields; data: Partial; upsert?: boolean; } +export interface GroupByQueryArgs { + selectedFields: CommonSelectedFields; + filter?: ObjectRecordFilter; + orderBy?: OrderByWithGroupBy; + groupBy: ObjectRecordGroupBy; + omitNullValues?: boolean; + viewId?: string; +} + +export type CommonQueryArgs = + | FindOneQueryArgs + | FindManyQueryArgs + | GroupByQueryArgs; 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 a36bd10156a..ac11854af9c 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 @@ -1,12 +1,12 @@ import { type AggregationField } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util'; -interface SelectedFields { - [key: string]: boolean | SelectedFields; +export interface CommonSelectedFields { + [key: string]: boolean | CommonSelectedFields; } export type CommonSelectedFieldsResult = { - select: SelectedFields; - relations: SelectedFields; + select: CommonSelectedFields; + relations: CommonSelectedFields; //TODO = Refacto-common - to update when rest api will handle aggregates aggregate: Record; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts index a742226d97e..38486642bbd 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { CompositeFieldSubFieldName, - FieldMetadataType, ObjectRecord, PartialFieldMetadataItemOption, RecordFilterGroupLogicalOperator, @@ -26,7 +25,7 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; -import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; +import { computeIsNumericReturningAggregate } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; @@ -152,10 +151,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver if (aggregateExpression) { queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); - const isNumericReturningAggregate = this.isNumericReturningAggregate( - aggregationField.aggregateOperation, - aggregationField.fromFieldType, - ); + const isNumericReturningAggregate = + computeIsNumericReturningAggregate( + aggregationField.aggregateOperation, + aggregationField.fromFieldType, + ); if (isNumericReturningAggregate) { queryBuilder.andHaving(`${aggregateExpression} != 0`); @@ -172,7 +172,11 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver const result = await queryBuilder.getRawMany(); - return formatResultWithGroupByDimensionValues(result, groupByDefinitions); + return formatResultWithGroupByDimensionValues( + result, + groupByDefinitions, + Object.keys(executionArgs.graphqlQuerySelectedFieldsResult.aggregate), + ); } private async addFiltersFromView({ @@ -266,39 +270,6 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver return appliedFilters; } - private isNumericReturningAggregate( - operation: AggregateOperations, - fromFieldType: FieldMetadataType, - ): boolean { - if ( - operation === AggregateOperations.COUNT || - operation === AggregateOperations.COUNT_UNIQUE_VALUES || - operation === AggregateOperations.COUNT_EMPTY || - operation === AggregateOperations.COUNT_NOT_EMPTY || - operation === AggregateOperations.COUNT_TRUE || - operation === AggregateOperations.COUNT_FALSE || - operation === AggregateOperations.PERCENTAGE_EMPTY || - operation === AggregateOperations.PERCENTAGE_NOT_EMPTY - ) { - return true; - } - - if ( - operation === AggregateOperations.MIN || - operation === AggregateOperations.MAX || - operation === AggregateOperations.AVG || - operation === AggregateOperations.SUM - ) { - return [ - FieldMetadataType.NUMBER, - FieldMetadataType.NUMERIC, - FieldMetadataType.CURRENCY, - ].includes(fromFieldType); - } - - return false; - } - async validate( _args: GroupByResolverArgs, _options: WorkspaceQueryRunnerOptions, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util.ts new file mode 100644 index 00000000000..abfecb5cfbf --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/compute-is-numeric-returning-aggregate.util.ts @@ -0,0 +1,36 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; + +export const computeIsNumericReturningAggregate = ( + operation: AggregateOperations, + fromFieldType: FieldMetadataType, +): boolean => { + if ( + operation === AggregateOperations.COUNT || + operation === AggregateOperations.COUNT_UNIQUE_VALUES || + operation === AggregateOperations.COUNT_EMPTY || + operation === AggregateOperations.COUNT_NOT_EMPTY || + operation === AggregateOperations.COUNT_TRUE || + operation === AggregateOperations.COUNT_FALSE || + operation === AggregateOperations.PERCENTAGE_EMPTY || + operation === AggregateOperations.PERCENTAGE_NOT_EMPTY + ) { + return true; + } + + if ( + operation === AggregateOperations.MIN || + operation === AggregateOperations.MAX || + operation === AggregateOperations.AVG || + operation === AggregateOperations.SUM + ) { + return [ + FieldMetadataType.NUMBER, + FieldMetadataType.NUMERIC, + FieldMetadataType.CURRENCY, + ].includes(fromFieldType); + } + + return false; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts index 2e8e770cd2e..561fdaf2ec5 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util.ts @@ -7,57 +7,60 @@ import { import { type IEdge } from 'src/engine/api/graphql/workspace-query-runner/interfaces/edge.interface'; import { type IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/group-by-connection.interface'; -import { removeQuotes } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/remove-quote.util'; +import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; -export const formatResultWithGroupByDimensionValues = ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - result: any[], +export const formatResultWithGroupByDimensionValues = < + T extends + | IGroupByConnection> + | CommonGroupByOutputItem, +>( + result: Record[], groupByColumnsWithQuotes: { columnNameWithQuotes: string; alias: string; dateGranularity?: ObjectRecordGroupByDateGranularity; }[], -): IGroupByConnection>[] => { - let formattedResult: IGroupByConnection>[] = - []; + aggregateFieldNames: string[], +): T[] => { + let formattedResult: T[] = []; result.forEach((group) => { - let dimensionValues = []; + let dimensionValues: string[] = []; for (const groupByColumn of groupByColumnsWithQuotes) { - dimensionValues.push(group[groupByColumn.alias]); + dimensionValues.push( + getTranslatedValueIfApplicable( + group[groupByColumn.alias], + groupByColumn.dateGranularity, + ), + ); } - const groupWithValueMappedToUnaliasedColumn = { - ...group, - ...groupByColumnsWithQuotes.reduce>( - (acc, groupByColumn) => { - const value = group[groupByColumn.alias]; - acc[removeQuotes(groupByColumn.columnNameWithQuotes)] = - getTranslatedValueIfApplicable( - value, - groupByColumn.dateGranularity, - ); + const aggregateValues = aggregateFieldNames.reduce( + (acc, fieldName) => { + if (fieldName in group) { + acc[fieldName] = group[fieldName]; + } - return acc; - }, - {}, - ), - }; + return acc; + }, + {} as Record, + ); formattedResult.push({ groupByDimensionValues: dimensionValues, - ...groupWithValueMappedToUnaliasedColumn, - }); + ...aggregateValues, + //TODO: Refacto-common - remove generic type + } as T); }); return formattedResult; }; const getTranslatedValueIfApplicable = ( - value: unknown, + value: string, dateGranularity?: ObjectRecordGroupByDateGranularity, -) => { +): string => { switch (dateGranularity) { case ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK: switch (value) { diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts index dc947939eb1..65464a7485e 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts @@ -11,6 +11,7 @@ import { type FindDuplicatesResolverArgs, type FindManyResolverArgs, type FindOneResolverArgs, + GroupByResolverArgs, type MergeManyResolverArgs, type ResolverArgs, ResolverArgsType, @@ -108,7 +109,6 @@ export class QueryRunnerArgsFactory { options.objectMetadataItemWithFieldMaps, ), }; - case ResolverArgsType.FindDuplicates: return { ...args, @@ -146,6 +146,14 @@ export class QueryRunnerArgsFactory { .conflictPriorityIndex, dryRun: (args as MergeManyResolverArgs).dryRun, } satisfies MergeManyResolverArgs; + case ResolverArgsType.GroupBy: + return { + ...args, + filter: this.overrideFilterByFieldMetadata( + (args as GroupByResolverArgs).filter, + options.objectMetadataItemWithFieldMaps, + ), + }; default: return args; } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts index 26162d5e2fb..0caafcd8f2f 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts @@ -8,6 +8,7 @@ import { type FindDuplicatesResolverArgs, type FindManyResolverArgs, type FindOneResolverArgs, + type GroupByResolverArgs, type MergeManyResolverArgs, type RestoreManyResolverArgs, type UpdateManyResolverArgs, @@ -45,4 +46,6 @@ export type WorkspacePreQueryHookPayload = T extends 'createMany' ? DestroyOneResolverArgs : T extends 'mergeMany' ? MergeManyResolverArgs - : never; + : T extends 'groupBy' + ? GroupByResolverArgs + : never; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts index d01d82c1904..450bdea7b5b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-many-resolver.factory.ts @@ -12,7 +12,6 @@ import { import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { GraphqlQueryCreateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-many-resolver.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; @@ -46,20 +45,11 @@ export class CreateManyResolverFactory const featureFlagsMap = workspaceDataSource.featureFlagMap; if (featureFlagsMap[FeatureFlagKey.IS_COMMON_API_ENABLED]) { - const graphqlQueryParser = new GraphqlQueryParser( - internalContext.objectMetadataItemWithFieldMaps, - internalContext.objectMetadataMaps, - ); - - const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( - internalContext.objectMetadataItemWithFieldMaps, - graphqlFields(info), - internalContext.objectMetadataMaps, - ); + const selectedFields = graphqlFields(info); try { const records = await this.commonCreateManyQueryRunnerService.run({ - args: { ...args, selectedFieldsResult }, + args: { ...args, selectedFields }, authContext: internalContext.authContext, objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataItemWithFieldMaps: diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts index 26eef39ef0d..5c9028451cd 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/create-one-resolver.factory.ts @@ -11,7 +11,6 @@ import { import { type WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { CommonCreateOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-one-query-runner.service'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { GraphqlQueryCreateOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-create-one-resolver.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; @@ -43,20 +42,11 @@ export class CreateOneResolverFactory ); if (isCommonApiEnabled) { - const graphqlQueryParser = new GraphqlQueryParser( - internalContext.objectMetadataItemWithFieldMaps, - internalContext.objectMetadataMaps, - ); - - const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( - internalContext.objectMetadataItemWithFieldMaps, - graphqlFields(info), - internalContext.objectMetadataMaps, - ); + const selectedFields = graphqlFields(info); try { const record = await this.commonCreateOneQueryRunnerService.run({ - args: { ...args, selectedFieldsResult }, + args: { ...args, selectedFields }, authContext: internalContext.authContext, objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataItemWithFieldMaps: diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts index 3336b5bfdd8..7f1d15a3195 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-many-resolver.factory.ts @@ -11,7 +11,6 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; @@ -43,26 +42,22 @@ export class FindManyResolverFactory ); if (isCommonApiEnabled) { - const graphqlQueryParser = new GraphqlQueryParser( - internalContext.objectMetadataItemWithFieldMaps, - internalContext.objectMetadataMaps, - ); - - const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( - internalContext.objectMetadataItemWithFieldMaps, - graphqlFields(info), - internalContext.objectMetadataMaps, - ); + const selectedFields = graphqlFields(info); try { - const { records, aggregatedValues, totalCount, pageInfo } = - await this.commonFindManyQueryRunnerService.run({ - args: { ...args, selectedFieldsResult }, - authContext: internalContext.authContext, - objectMetadataMaps: internalContext.objectMetadataMaps, - objectMetadataItemWithFieldMaps: - internalContext.objectMetadataItemWithFieldMaps, - }); + const { + records, + aggregatedValues, + totalCount, + pageInfo, + selectedFieldsResult, + } = await this.commonFindManyQueryRunnerService.run({ + args: { ...args, selectedFields }, + authContext: internalContext.authContext, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }); const typeORMObjectRecordsParser = new ObjectRecordsToGraphqlConnectionHelper( diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts index bdc1bfc794d..e7dfe28ff2d 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/find-one-resolver.factory.ts @@ -10,7 +10,6 @@ import { import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; import { CommonFindOneQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-one-query-runner.service'; -import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper'; import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service'; import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; @@ -43,19 +42,10 @@ export class FindOneResolverFactory if (isCommonApiEnabled) { try { - const graphqlQueryParser = new GraphqlQueryParser( - internalContext.objectMetadataItemWithFieldMaps, - internalContext.objectMetadataMaps, - ); - - const selectedFieldsResult = graphqlQueryParser.parseSelectedFields( - internalContext.objectMetadataItemWithFieldMaps, - graphqlFields(info), - internalContext.objectMetadataMaps, - ); + const selectedFields = graphqlFields(info); const record = await this.commonFindOneQueryRunnerService.run({ - args: { ...args, selectedFieldsResult }, + args: { ...args, selectedFields }, authContext: internalContext.authContext, objectMetadataMaps: internalContext.objectMetadataMaps, objectMetadataItemWithFieldMaps: diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts index a8c80b454c7..8eb18ead704 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; +import graphqlFields from 'graphql-fields'; + import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { type WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface'; import { @@ -8,8 +10,12 @@ import { } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface'; +import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service'; import { GraphqlQueryGroupByResolverService } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service'; +import { workspaceQueryRunnerGraphqlApiExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-query-runner-graphql-api-exception-handler.util'; import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; @Injectable() export class GroupByResolverFactory @@ -19,6 +25,8 @@ export class GroupByResolverFactory constructor( private readonly graphqlQueryRunnerService: GraphqlQueryGroupByResolverService, + private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService, + private readonly featureFlagService: FeatureFlagService, ) {} create( @@ -27,6 +35,27 @@ export class GroupByResolverFactory const internalContext = context; return async (_source, args, _context, info) => { + const isCommonApiEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_COMMON_API_ENABLED, + internalContext.authContext.workspace?.id as string, + ); + + if (isCommonApiEnabled) { + const selectedFields = graphqlFields(info); + + try { + return await this.commonGroupByQueryRunnerService.run({ + args: { ...args, selectedFields }, + authContext: internalContext.authContext, + objectMetadataMaps: internalContext.objectMetadataMaps, + objectMetadataItemWithFieldMaps: + internalContext.objectMetadataItemWithFieldMaps, + }); + } catch (error) { + return workspaceQueryRunnerGraphqlApiExceptionHandler(error); + } + } + const options: WorkspaceQueryRunnerOptions = { authContext: internalContext.authContext, info, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 487a5a632ac..97bff3424d1 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -29,6 +29,7 @@ export enum ResolverArgsType { DestroyMany = 'DestroyMany', DestroyOne = 'DestroyOne', MergeMany = 'MergeMany', + GroupBy = 'GroupBy', } export interface FindManyResolverArgs< diff --git a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts index 9a8d6d02fc7..f9d5501d568 100644 --- a/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts +++ b/packages/twenty-server/src/engine/api/rest/core/controllers/rest-api-core.controller.ts @@ -66,6 +66,20 @@ export class RestApiCoreController { res.status(201).send(result); } + //TODO: Refacto-common - Document this endpoint + @Get('*/groupBy') + async handleApiGroupBy( + @Req() request: AuthenticatedRequest, + @Res() res: Response, + ) { + this.logger.log( + `[REST API] Processing GROUP BY request to ${request.path} on workspace ${request.workspaceId}`, + ); + const result = await this.restApiCoreService.groupBy(request); + + res.status(200).send(result); + } + @Get('*') async handleApiGet( @Req() request: AuthenticatedRequest, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts index ad8c3b33b94..a69bc05023e 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-many.handler.ts @@ -16,7 +16,6 @@ import { parseUpsertRestRequest } from 'src/engine/api/rest/input-request-parser import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; - @Injectable() export class RestApiCreateManyHandler extends RestApiBaseHandler { constructor( @@ -35,7 +34,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler { objectMetadataMaps, } = await this.buildCommonOptions(request); - const selectedFieldsResult = await this.computeSelectedFields({ + const selectedFields = await this.computeSelectedFields({ depth, objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMaps, @@ -43,7 +42,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler { }); const records = await this.commonCreateManyQueryRunnerService.run({ - args: { data, selectedFieldsResult, upsert }, + args: { data, selectedFields, upsert }, authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts index 323fbdfd00a..bed04b57436 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-create-one.handler.ts @@ -35,7 +35,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler { objectMetadataMaps, } = await this.buildCommonOptions(request); - const selectedFieldsResult = await this.computeSelectedFields({ + const selectedFields = await this.computeSelectedFields({ depth, objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMaps, @@ -43,7 +43,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler { }); const record = await this.commonCreateOneQueryRunnerService.run({ - args: { data, selectedFieldsResult, upsert }, + args: { data, selectedFields, upsert }, authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts index 394fa3bdd87..da7daf15955 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-many.handler.ts @@ -102,7 +102,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler { objectMetadataMaps, } = await this.buildCommonOptions(request); - const selectedFieldsResult = await this.computeSelectedFields({ + const selectedFields = await this.computeSelectedFields({ depth: parsedArgs.depth, objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMaps, @@ -111,7 +111,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler { const { records, aggregatedValues, pageInfo } = await this.commonFindManyQueryRunnerService.run({ - args: { ...parsedArgs, selectedFieldsResult }, + args: { ...parsedArgs, selectedFields }, authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts index 051caf2b781..09451a9918e 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-find-one.handler.ts @@ -66,7 +66,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler { objectMetadataMaps, } = await this.buildCommonOptions(request); - const selectedFieldsResult = await this.computeSelectedFields({ + const selectedFields = await this.computeSelectedFields({ depth, objectMetadataMapItem: objectMetadataItemWithFieldMaps, objectMetadataMaps, @@ -74,7 +74,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler { }); const record = await this.commonFindOneQueryRunnerService.run({ - args: { filter, selectedFieldsResult }, + args: { filter, selectedFields }, authContext, objectMetadataMaps, objectMetadataItemWithFieldMaps, 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 new file mode 100644 index 00000000000..660ae419a0a --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; + +import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler'; + +import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service'; +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 { parseOmitNullValuesRestRequest } from 'src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-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'; +import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; +import { workspaceQueryRunnerRestApiExceptionHandler } from 'src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util'; + +@Injectable() +export class RestApiGroupByHandler extends RestApiBaseHandler { + constructor( + private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService, + ) { + super(); + } + + async handle(request: AuthenticatedRequest) { + try { + const { + authContext, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + } = await this.buildCommonOptions(request); + + const { + filter, + orderBy, + viewId, + groupBy, + selectedFields, + omitNullValues, + } = this.parseRequestArgs(request); + + return await this.commonGroupByQueryRunnerService.run({ + args: { + filter, + orderBy, + viewId, + groupBy, + selectedFields, + omitNullValues, + }, + authContext, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + }); + } catch (error) { + throw workspaceQueryRunnerRestApiExceptionHandler(error); + } + } + + private parseRequestArgs(request: AuthenticatedRequest) { + const orderByWithGroupBy = parseOrderByWithGroupByRestRequest(request); + const filter = parseFilterRestRequest(request); + const viewId = parseViewIdRestRequest(request); + const groupBy = parseGroupByRestRequest(request); + const aggregateFields = parseAggregateFieldsRestRequest(request); + const omitNullValues = parseOmitNullValuesRestRequest(request); + const selectedFields = { ...aggregateFields, groupByDimensionValues: true }; + + return { + filter, + orderBy: orderByWithGroupBy, + viewId, + groupBy, + selectedFields, + omitNullValues, + }; + } +} diff --git a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts index b0645a92a4b..a04a3a838a7 100644 --- a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts @@ -13,6 +13,7 @@ import { In, type ObjectLiteral } from 'typeorm'; import { WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface'; import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; import { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser'; import { encodeCursor } from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util'; import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory'; @@ -104,7 +105,9 @@ export abstract class RestApiBaseHandler { protected abstract handle( request: AuthenticatedRequest, - ): Promise; + ): Promise< + FormatResult | { data: FormatResult[] } | CommonGroupByOutputItem[] + >; public async getRepositoryAndMetadataOrFail(request: AuthenticatedRequest) { const { workspace, apiKey, userWorkspaceId } = request; diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts index 69bb21896a4..247d0c75299 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils.ts @@ -32,7 +32,7 @@ export const parseCorePath = ( return { object: queryAction[1] }; } - if (queryAction[1] === 'duplicates') { + if (queryAction[1] === 'duplicates' || queryAction[1] === 'group') { return { object: queryAction[0] }; } diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts index 8fc8dbe0d8a..7ee46bcf01d 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-api-core.module.ts @@ -9,6 +9,7 @@ import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest- import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler'; import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; +import { RestApiGroupByHandler } from 'src/engine/api/rest/core/handlers/rest-api-group-by.handler'; import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module'; import { coreQueryBuilderFactories } from 'src/engine/api/rest/core/query-builder/factories/factories'; @@ -35,6 +36,7 @@ const restApiCoreResolvers = [ RestApiFindOneHandler, RestApiFindManyHandler, RestApiFindDuplicatesHandler, + RestApiGroupByHandler, ]; @Module({ diff --git a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts index 4b7735669e7..00f862293b2 100644 --- a/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/rest-to-common-args-handlers/selected-fields-handler.ts @@ -3,7 +3,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { FieldMetadataType, ObjectsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { CommonSelectedFieldsResult } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; import { MAX_DEPTH } from 'src/engine/api/rest/input-request-parsers/constants/max-depth.constant'; import { Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils'; @@ -11,6 +11,10 @@ import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/typ import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +type SelectFields = { + [key: string]: boolean | SelectFields; +}; + @Injectable() export class RestToCommonSelectedFieldsHandler { computeFromDepth = ({ @@ -23,17 +27,16 @@ export class RestToCommonSelectedFieldsHandler { objectMetadataMaps: ObjectMetadataMaps; objectMetadataMapItem: ObjectMetadataItemWithFieldMaps; depth: Depth | undefined; - }): CommonSelectedFieldsResult => { + }): CommonSelectedFields => { const restrictedFields = objectsPermissions[objectMetadataMapItem.id].restrictedFields; - const { relations, relationsSelectFields } = - this.getRelationsAndRelationsSelectFields({ - objectMetadataMaps, - objectMetadataMapItem, - objectsPermissions, - depth, - }); + const relationsSelectFields = this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem, + objectsPermissions, + depth, + }); const selectableFields = getAllSelectableFields({ restrictedFields, @@ -43,12 +46,8 @@ export class RestToCommonSelectedFieldsHandler { }); return { - select: { - ...selectableFields, - ...relationsSelectFields, - }, - relations, - aggregate: {}, + ...selectableFields, + ...relationsSelectFields, }; }; @@ -63,20 +62,9 @@ export class RestToCommonSelectedFieldsHandler { objectsPermissions: ObjectsPermissions; depth: Depth | undefined; }) { - if (!isDefined(depth) || depth === 0) { - return { - relations: {}, - relationsSelectFields: {}, - }; - } + if (!isDefined(depth) || depth === 0) return {}; - let relations: { [key: string]: boolean | { [key: string]: boolean } } = {}; - - let relationsSelectFields: { - [key: string]: - | boolean - | { [key: string]: boolean | { [key: string]: boolean } }; - } = {}; + let relationsSelectFields: SelectFields = {}; for (const field of Object.values(objectMetadataMapItem.fieldsById)) { if (!isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION)) @@ -104,35 +92,23 @@ export class RestToCommonSelectedFieldsHandler { depth === MAX_DEPTH && isDefined(field.relationTargetObjectMetadataId) ) { - const { - relations: depth2Relations, - relationsSelectFields: depth2RelationsSelectFields, - } = this.getRelationsAndRelationsSelectFields({ - objectMetadataMaps, - objectMetadataMapItem: relationTargetObjectMetadata, - objectsPermissions, - depth: 1, - }) as { - relations: { [key: string]: boolean }; - relationsSelectFields: { - [key: string]: boolean; - }; - }; - - relations[field.name] = depth2Relations as { - [key: string]: boolean; - }; + const depth2RelationsSelectFields = + this.getRelationsAndRelationsSelectFields({ + objectMetadataMaps, + objectMetadataMapItem: relationTargetObjectMetadata, + objectsPermissions, + depth: 1, + }); relationsSelectFields[field.name] = { ...relationFieldSelectFields, ...depth2RelationsSelectFields, }; } else { - relations[field.name] = true; relationsSelectFields[field.name] = relationFieldSelectFields; } } - return { relations, relationsSelectFields }; + return relationsSelectFields; } } diff --git a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts index bb7451a024c..6621e55e585 100644 --- a/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts +++ b/packages/twenty-server/src/engine/api/rest/core/services/rest-api-core.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; @@ -8,6 +8,7 @@ import { RestApiDeleteOneHandler } from 'src/engine/api/rest/core/handlers/rest- import { RestApiFindDuplicatesHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-duplicates.handler'; import { RestApiFindManyHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-many.handler'; import { RestApiFindOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-find-one.handler'; +import { RestApiGroupByHandler } from 'src/engine/api/rest/core/handlers/rest-api-group-by.handler'; import { RestApiUpdateOneHandler } from 'src/engine/api/rest/core/handlers/rest-api-update-one.handler'; import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils'; import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; @@ -24,6 +25,7 @@ export class RestApiCoreService { private readonly restApiFindOneHandler: RestApiFindOneHandler, private readonly restApiFindManyHandler: RestApiFindManyHandler, private readonly restApiFindDuplicatesHandler: RestApiFindDuplicatesHandler, + private readonly restApiGroupByHandler: RestApiGroupByHandler, private readonly featureFlagService: FeatureFlagService, ) {} @@ -84,4 +86,16 @@ export class RestApiCoreService { } } } + + async groupBy(request: AuthenticatedRequest) { + const isCommonApiEnabled = await this.isCommonApiEnabled(request); + + if (isCommonApiEnabled) { + return await this.restApiGroupByHandler.handle(request); + } else { + throw new BadRequestException( + 'Activate feature flag to use GroupBy in the REST API', + ); + } + } } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts new file mode 100644 index 00000000000..6fcb08148e3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts @@ -0,0 +1,77 @@ +import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; + +describe('parseAggregateFieldsRestRequest', () => { + it('should parse single aggregate field', () => { + const request: any = { + query: { aggregate: '["countNotEmptyId"]' }, + }; + + expect(parseAggregateFieldsRestRequest(request)).toEqual({ + countNotEmptyId: true, + }); + }); + + it('should parse multiple aggregate fields', () => { + const request: any = { + query: { + aggregate: '["countNotEmptyId", "countEmptyId"]', + }, + }; + + expect(parseAggregateFieldsRestRequest(request)).toEqual({ + countNotEmptyId: true, + countEmptyId: true, + }); + }); + + it('should parse empty array', () => { + const request: any = { + query: { aggregate: '[]' }, + }; + + expect(parseAggregateFieldsRestRequest(request)).toEqual({}); + }); + + it('should throw if aggregate parameter is not a string', () => { + const request: any = { + query: { aggregate: ['countNotEmptyId'] }, + }; + + expect(() => parseAggregateFieldsRestRequest(request)).toThrow( + new RestInputRequestParserException( + 'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]', + RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM, + ), + ); + }); + + it('should throw if aggregate parameter is not valid JSON', () => { + const request: any = { + query: { aggregate: 'not-valid-json' }, + }; + + expect(() => parseAggregateFieldsRestRequest(request)).toThrow( + new RestInputRequestParserException( + 'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]', + RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM, + ), + ); + }); + + it('should throw if aggregate parameter is undefined', () => { + const request: any = { + query: {}, + }; + + expect(() => parseAggregateFieldsRestRequest(request)).toThrow( + new RestInputRequestParserException( + 'Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]', + RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM, + ), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts new file mode 100644 index 00000000000..53657bf37f4 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts @@ -0,0 +1,37 @@ +import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseAggregateFieldsRestRequest = ( + request: AuthenticatedRequest, +): CommonSelectedFields => { + const aggregateFieldsQuery = request.query.aggregate; + + if (typeof aggregateFieldsQuery !== 'string') { + throw new RestInputRequestParserException( + `Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]`, + RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM, + ); + } + + try { + const aggregateFields = JSON.parse(aggregateFieldsQuery); + + return aggregateFields.reduce( + (acc: CommonSelectedFields, field: string) => { + acc[field] = true; + + return acc; + }, + {}, + ); + } catch { + throw new RestInputRequestParserException( + `Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]`, + RestInputRequestParserExceptionCode.INVALID_AGGREGATE_FIELDS_QUERY_PARAM, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/__tests__/parse-depth-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/__tests__/parse-depth-rest-request.util.spec.ts new file mode 100644 index 00000000000..0fd52611b4a --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/__tests__/parse-depth-rest-request.util.spec.ts @@ -0,0 +1,46 @@ +import { parseDepthRestRequest } from 'src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util'; +import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; + +describe('parseDepthRestRequest', () => { + it('should return 0 when depth parameter is not provided', () => { + const request: any = { + query: {}, + }; + + expect(parseDepthRestRequest(request)).toBe(0); + }); + + it('should parse depth=0', () => { + const request: any = { + query: { depth: '0' }, + }; + + expect(parseDepthRestRequest(request)).toBe(0); + }); + + it('should throw if depth is not a number', () => { + const request: any = { + query: { depth: 'invalid' }, + }; + + expect(() => parseDepthRestRequest(request)).toThrow( + RestInputRequestParserException, + ); + expect(() => parseDepthRestRequest(request)).toThrow( + "'depth=invalid' parameter invalid. Allowed values are 0, 1", + ); + }); + + it('should throw if depth is not in allowed values (2)', () => { + const request: any = { + query: { depth: '2' }, + }; + + expect(() => parseDepthRestRequest(request)).toThrow( + RestInputRequestParserException, + ); + expect(() => parseDepthRestRequest(request)).toThrow( + "'depth=2' parameter invalid. Allowed values are 0, 1", + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util.ts index 06d82af9c7f..0935225dccc 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/depth-parser-utils/parse-depth-rest-request.util.ts @@ -1,5 +1,7 @@ -import { BadRequestException } from '@nestjs/common'; - +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { type Depth } from 'src/engine/api/rest/input-request-parsers/types/depth.type'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; @@ -13,12 +15,13 @@ export const parseDepthRestRequest = (request: AuthenticatedRequest): Depth => { const ALLOWED_DEPTH_VALUES: Depth[] = [0, 1]; if (isNaN(depth) || !ALLOWED_DEPTH_VALUES.includes(depth)) { - throw new BadRequestException( + throw new RestInputRequestParserException( `'depth=${ request.query.depth }' parameter invalid. Allowed values are ${ALLOWED_DEPTH_VALUES.join( ', ', )}`, + RestInputRequestParserExceptionCode.INVALID_DEPTH_QUERY_PARAM, ); } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util.ts index 5789467df34..0d41d1a270b 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/check-filter-query.util.ts @@ -1,4 +1,7 @@ -import { BadRequestException } from '@nestjs/common'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; export const checkFilterQuery = (filterQuery: string): void => { const countOpenedBrackets = (filterQuery.match(/\(/g) || []).length; @@ -13,8 +16,9 @@ export const checkFilterQuery = (filterQuery: string): void => { Math.abs(diff) > 1 ? 's are' : ' is' }`; - throw new BadRequestException( + throw new RestInputRequestParserException( `'filter' invalid. ${hint} missing in the query`, + RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM, ); } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util.ts index 3a991cafa72..1c1ca18cb00 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-without-metadata-validation.util.ts @@ -5,6 +5,10 @@ import { type FieldValue } from 'src/engine/api/rest/core/types/field-value.type import { formatFieldValue } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/format-field-values.util'; import { parseBaseFilter } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-base-filter.util'; import { parseFilterContent } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-content.util'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; //TODO : Refacto-common - Rename after deleting parseFilter export const parseFilterWithoutMetadataValidation = ( @@ -29,8 +33,9 @@ export const parseFilterWithoutMetadataValidation = ( if (conjunction === Conjunctions.not) { if (subResult.length > 1) { - throw new BadRequestException( + throw new RestInputRequestParserException( `'filter' invalid. 'not' conjunction should contain only 1 condition. eg: not(field[eq]:1)`, + RestInputRequestParserExceptionCode.INVALID_FILTER_QUERY_PARAM, ); } // @ts-expect-error legacy noImplicitAny diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/__tests__/parse-group-by-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/__tests__/parse-group-by-rest-request.util.spec.ts new file mode 100644 index 00000000000..30460160a41 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/__tests__/parse-group-by-rest-request.util.spec.ts @@ -0,0 +1,66 @@ +import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util'; +import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; + +describe('parseGroupByRestRequest', () => { + it('should parse mixed field types', () => { + const request: any = { + query: { + group_by: + '[{"firstField": true}, {"fieldCurrency": {"amountMicros": true}}, {"createdAt": {"granularity": "WEEK"}}]', + }, + }; + + expect(parseGroupByRestRequest(request)).toEqual([ + { firstField: true }, + { fieldCurrency: { amountMicros: true } }, + { createdAt: { granularity: 'WEEK' } }, + ]); + }); + + it('should parse empty array', () => { + const request: any = { + query: { group_by: '[]' }, + }; + + expect(parseGroupByRestRequest(request)).toEqual([]); + }); + + it('should throw if group_by parameter is not a string', () => { + const request: any = { + query: { group_by: [{ firstField: true }] }, + }; + + expect(() => parseGroupByRestRequest(request)).toThrow( + RestInputRequestParserException, + ); + expect(() => parseGroupByRestRequest(request)).toThrow( + `Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`, + ); + }); + + it('should throw if group_by parameter is not valid JSON', () => { + const request: any = { + query: { group_by: 'not-valid-json' }, + }; + + expect(() => parseGroupByRestRequest(request)).toThrow( + RestInputRequestParserException, + ); + expect(() => parseGroupByRestRequest(request)).toThrow( + `Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`, + ); + }); + + it('should throw if group_by parameter is undefined', () => { + const request: any = { + query: {}, + }; + + expect(() => parseGroupByRestRequest(request)).toThrow( + RestInputRequestParserException, + ); + expect(() => parseGroupByRestRequest(request)).toThrow( + `Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util.ts new file mode 100644 index 00000000000..fdb46c6b3d1 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util.ts @@ -0,0 +1,29 @@ +import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseGroupByRestRequest = ( + request: AuthenticatedRequest, +): ObjectRecordGroupBy => { + const groupByQuery = request.query.group_by; + + if (typeof groupByQuery !== 'string') { + throw new RestInputRequestParserException( + `Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`, + RestInputRequestParserExceptionCode.INVALID_GROUP_BY_QUERY_PARAM, + ); + } + + try { + return JSON.parse(groupByQuery); + } catch { + throw new RestInputRequestParserException( + `Invalid group_by query parameter - should be a valid array of objects - ex: [{"firstField": true}, {"secondField": {"subField": true}}, {"dateField": {"granularity": 'DAY'}}]`, + RestInputRequestParserExceptionCode.INVALID_GROUP_BY_QUERY_PARAM, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util.ts index 18b237422ee..70db46bfb58 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/limit-parser-utils/parse-limit-rest-request.util.ts @@ -1,10 +1,12 @@ -import { BadRequestException } from '@nestjs/common'; - import { QUERY_DEFAULT_LIMIT_RECORDS, QUERY_MAX_RECORDS, } from 'twenty-shared/constants'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; import { type RequestContext } from 'src/engine/api/rest/types/RequestContext'; @@ -18,8 +20,9 @@ export const parseLimitRestRequest = ( const limit = +request.query.limit; if (isNaN(limit) || limit < 0) { - throw new BadRequestException( + throw new RestInputRequestParserException( `limit '${request.query.limit}' is invalid. Should be an integer`, + RestInputRequestParserExceptionCode.INVALID_LIMIT_QUERY_PARAM, ); } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util.ts new file mode 100644 index 00000000000..4b8fc1d8978 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/omit-null-values-parser-utils/parse-omit-null-values-rest-request.util.ts @@ -0,0 +1,13 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseOmitNullValuesRestRequest = ( + request: AuthenticatedRequest, +): boolean => { + if (!isDefined(request.query.omit_null_values)) { + return false; + } + + return request.query.omit_null_values === 'true'; +}; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts index a913985d681..9cc632ab0b9 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts @@ -1,13 +1,15 @@ //TODO : Refacto-common - remove this comment - This parser is a copy of the OrderByInputFactory without objectMetadata dependency. Validation will be done in common layer -import { BadRequestException } from '@nestjs/common'; - import { OrderByDirection } from 'twenty-shared/types'; import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { DEFAULT_ORDER_DIRECTION } from 'src/engine/api/rest/input-factories/order-by-input.factory'; import { addDefaultOrderById } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/add-default-order-by-id.util'; +import { + RestInputRequestParserException, + RestInputRequestParserExceptionCode, +} from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; export const parseOrderByRestRequest = ( @@ -33,12 +35,13 @@ export const parseOrderByRestRequest = ( // fields -> [field_1] ; direction -> AscNullsFirst if (!(direction in OrderByDirection)) { - throw new BadRequestException( + throw new RestInputRequestParserException( `'order_by' direction '${direction}' invalid. Allowed values are '${Object.values( OrderByDirection, ).join( "', '", )}'. eg: ?order_by=field_1[AscNullsFirst],field_2[DescNullsLast],field_3`, + RestInputRequestParserExceptionCode.INVALID_ORDER_BY_QUERY_PARAM, ); } diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/__tests__/parse-order-by-with-group-by-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/__tests__/parse-order-by-with-group-by-rest-request.util.spec.ts new file mode 100644 index 00000000000..d2ebd6c63e3 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/__tests__/parse-order-by-with-group-by-rest-request.util.spec.ts @@ -0,0 +1,50 @@ +import { BadRequestException } from '@nestjs/common'; + +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'; + +describe('parseOrderByWithGroupByRestRequest', () => { + it('should parse mixed order by types', () => { + const request: any = { + query: { + order_by: + '[{"field_1": "AscNullsFirst"}, {"fieldCurrency": {"amountMicros": "DescNullsLast"}}, {"aggregate": {"countNotEmptyId": "AscNullsFirst"}}, {"createdAt": {"orderBy": "DescNullsLast", "granularity": "WEEK"}}]', + }, + }; + + expect(parseOrderByWithGroupByRestRequest(request)).toEqual([ + { field_1: 'AscNullsFirst' }, + { fieldCurrency: { amountMicros: 'DescNullsLast' } }, + { aggregate: { countNotEmptyId: 'AscNullsFirst' } }, + { createdAt: { orderBy: 'DescNullsLast', granularity: 'WEEK' } }, + ]); + }); + + it('should parse empty array', () => { + const request: any = { + query: { order_by: '[]' }, + }; + + expect(parseOrderByWithGroupByRestRequest(request)).toEqual([]); + }); + + it('should return undefined if order_by parameter is undefined', () => { + const request: any = { + query: {}, + }; + + expect(parseOrderByWithGroupByRestRequest(request)).toBeUndefined(); + }); + + it('should throw if order_by parameter is not valid JSON', () => { + const request: any = { + query: { order_by: 'not-valid-json' }, + }; + + expect(() => parseOrderByWithGroupByRestRequest(request)).toThrow( + BadRequestException, + ); + expect(() => parseOrderByWithGroupByRestRequest(request)).toThrow( + `Invalid order_by query parameter - should be a valid array of objects - ex: [{"firstField": "AscNullsFirst"}, {"secondField": {"subField": "DescNullsLast"}}, {"aggregate": {"aggregateField": "DescNullsLast"}}, {dateField: {"orderBy": "AscNullsFirst", "granularity": "DAY"}}]`, + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util.ts new file mode 100644 index 00000000000..ce0e2a23020 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util.ts @@ -0,0 +1,23 @@ +import { BadRequestException } from '@nestjs/common'; + +import { type OrderByWithGroupBy } from 'twenty-shared/types'; + +import { RestInputRequestParserExceptionCode } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseOrderByWithGroupByRestRequest = ( + request: AuthenticatedRequest, +): OrderByWithGroupBy | undefined => { + const orderByWithGroupByQuery = request.query.order_by; + + if (typeof orderByWithGroupByQuery !== 'string') return undefined; + + try { + return JSON.parse(orderByWithGroupByQuery); + } catch { + throw new BadRequestException( + `Invalid order_by query parameter - should be a valid array of objects - ex: [{"firstField": "AscNullsFirst"}, {"secondField": {"subField": "DescNullsLast"}}, {"aggregate": {"aggregateField": "DescNullsLast"}}, {dateField: {"orderBy": "AscNullsFirst", "granularity": "DAY"}}]`, + RestInputRequestParserExceptionCode.INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM, + ); + } +}; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts new file mode 100644 index 00000000000..c607f37a78d --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception.ts @@ -0,0 +1,13 @@ +import { CustomException } from 'src/utils/custom-exception'; + +export class RestInputRequestParserException extends CustomException {} + +export enum RestInputRequestParserExceptionCode { + INVALID_AGGREGATE_FIELDS_QUERY_PARAM = 'INVALID_AGGREGATE_FIELDS_QUERY_PARAM', + INVALID_GROUP_BY_QUERY_PARAM = 'INVALID_GROUP_BY_QUERY_PARAM', + INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM = 'INVALID_ORDER_BY_WITH_GROUP_BY_QUERY_PARAM', + INVALID_ORDER_BY_QUERY_PARAM = 'INVALID_ORDER_BY_QUERY_PARAM', + INVALID_DEPTH_QUERY_PARAM = 'INVALID_DEPTH_QUERY_PARAM', + INVALID_LIMIT_QUERY_PARAM = 'INVALID_LIMIT_QUERY_PARAM', + INVALID_FILTER_QUERY_PARAM = 'INVALID_FILTER_QUERY_PARAM', +} diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/__tests__/parse-view-id-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/__tests__/parse-view-id-rest-request.util.spec.ts new file mode 100644 index 00000000000..023171a2ca7 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/__tests__/parse-view-id-rest-request.util.spec.ts @@ -0,0 +1,19 @@ +import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util'; + +describe('parseViewIdRestRequest', () => { + it('should return undefined if viewId missing', () => { + const request: any = { query: {} }; + + expect(parseViewIdRestRequest(request)).toBeUndefined(); + }); + + it('should return viewId when provided as string', () => { + const request: any = { + query: { viewId: '20202020-e29b-41d4-a716-446655440000' }, + }; + + expect(parseViewIdRestRequest(request)).toEqual( + '20202020-e29b-41d4-a716-446655440000', + ); + }); +}); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util.ts new file mode 100644 index 00000000000..271bbdcf434 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util.ts @@ -0,0 +1,15 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request'; + +export const parseViewIdRestRequest = ( + request: AuthenticatedRequest, +): string | undefined => { + if ( + !isDefined(request.query.viewId) || + typeof request.query.viewId !== 'string' + ) + return undefined; + + return request.query.viewId; +}; diff --git a/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts index 6278b857e9b..5c615df57fe 100644 --- a/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/rest/utils/workspace-query-runner-rest-api-exception-handler.util.ts @@ -1,7 +1,10 @@ +import { BadRequestException } from '@nestjs/common'; + import { type QueryFailedError } from 'typeorm'; import { CommonQueryRunnerException } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { commonQueryRunnerToRestApiExceptionHandler } from 'src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util'; +import { RestInputRequestParserException } from 'src/engine/api/rest/input-request-parsers/rest-input-request-parser.exception'; interface QueryFailedErrorWithCode extends QueryFailedError { code: string; @@ -13,6 +16,8 @@ export const workspaceQueryRunnerRestApiExceptionHandler = ( switch (true) { case error instanceof CommonQueryRunnerException: return commonQueryRunnerToRestApiExceptionHandler(error); + case error instanceof RestInputRequestParserException: + throw new BadRequestException(error.message); default: throw error; } diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts index cba1e7f607a..e352d443a73 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts @@ -105,6 +105,72 @@ describe('group-by resolvers (integration)', () => { expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]); expect(groupWithCityB.totalCount).toEqual(2); }); + + it('computes aggregated metrics on date time field', async () => { + const cityA = 'City A'; + const cityB = 'City B'; + + const person1 = ( + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: testPersonId, city: cityA }, + }), + ) + ).body.data.createPerson; + + const person2 = ( + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: testPerson2Id, city: cityB }, + }), + ) + ).body.data.createPerson; + + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: testPerson3Id, city: cityB }, + }), + ); + + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ city: true }], + gqlFields: 'minCreatedAt', + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(groups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ groupByDimensionValues: [cityA] }), + expect.objectContaining({ groupByDimensionValues: [cityB] }), + ]), + ); + + const groupWithCityA = groups.find( + (group: any) => group.groupByDimensionValues[0] === cityA, + ); + + expect(groupWithCityA.groupByDimensionValues).toEqual([cityA]); + expect(groupWithCityA.minCreatedAt).toEqual(person1.createdAt); + + const groupWithCityB = groups.find( + (group: any) => group.groupByDimensionValues[0] === cityB, + ); + + expect(groupWithCityB.groupByDimensionValues).toEqual([cityB]); + expect(groupWithCityB.minCreatedAt).toEqual(person2.createdAt); + }); }); describe('date range', () => {