From 0e7d18546d059aa5c8c473cdbaeb4b1ee3b4a786 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:14:02 +0100 Subject: [PATCH] [groupBy] Handle relations on groupBy with records (#15379) Following PR https://github.com/twentyhq/twenty/pull/15307 --- .../common-group-by-query-runner.service.ts | 5 +- ...ult-with-group-by-dimension-values.util.ts | 42 ++++-- .../services/group-by-with-records.service.ts | 56 +++++-- ...-with-records-resolver.integration-spec.ts | 138 ++++++++++++++++-- 4 files changed, 199 insertions(+), 42 deletions(-) diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 2da54996c4..a60bf4ffb3 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -72,7 +72,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic commonQueryParser, objectMetadataItemWithFieldMaps, authContext, - objectMetadataMaps, } = queryRunnerContext; const objectMetadataNameSingular = @@ -165,10 +164,8 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic queryBuilderWithFiltersAndWithoutGroupBy, queryBuilderWithGroupBy: queryBuilder, groupByDefinitions, - objectMetadataItemWithFieldMaps, - objectMetadataMaps, - repository, selectedFieldsResult: args.selectedFieldsResult, + queryRunnerContext, }); } 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 281409ffd9..0c76692176 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 @@ -1,5 +1,8 @@ import { t } from '@lingui/core/macro'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { + type ObjectRecord, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; @@ -8,21 +11,23 @@ import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-module import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util'; -export const formatResultWithGroupByDimensionValues = ({ +export const formatResultWithGroupByDimensionValues = async ({ groupsResult, groupByDefinitions, aggregateFieldNames, recordsResult, objectMetadataItemWithFieldMaps, objectMetadataMaps, + processRecord, }: { groupsResult: Record[]; groupByDefinitions: GroupByDefinition[]; aggregateFieldNames: string[]; + processRecord?: (record: ObjectRecord) => Promise; recordsResult?: Array>; objectMetadataItemWithFieldMaps?: ObjectMetadataItemWithFieldMaps; objectMetadataMaps?: ObjectMetadataMaps; -}): CommonGroupByOutputItem[] => { +}): Promise => { const formattedResult: CommonGroupByOutputItem[] = []; const recordsByGroupKey = new Map>>(); @@ -35,21 +40,30 @@ export const formatResultWithGroupByDimensionValues = ({ throw new Error('Metadata are required to format result'); } - recordsResult.forEach((entry) => { + if (!isDefined(processRecord)) { + throw new Error( + 'Process record is required to format results including records', + ); + } + + for (const entry of recordsResult) { const groupKey = createGroupKey(entry, groupByDefinitions); - const records: Record[] = ( - (entry.records as Array>) ?? [] - ).map((record) => { - return formatResult( - record, - objectMetadataItemWithFieldMaps, - objectMetadataMaps, - ); - }); + const records = await Promise.all( + ((entry.records as Array>) ?? []).map( + async (record) => + await processRecord( + formatResult( + record, + objectMetadataItemWithFieldMaps, + objectMetadataMaps, + ), + ), + ), + ); recordsByGroupKey.set(groupKey, records); - }); + } } groupsResult.forEach((group) => { diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index c41c56d166..df2242631a 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -1,39 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; + import { isDefined } from 'class-validator'; +import { ObjectRecord } from 'twenty-shared/types'; import { type ObjectLiteral } from 'typeorm'; +import { CommonResultGettersService } from 'src/engine/api/common/common-result-getters/common-result-getters.service'; +import { CommonExtendedQueryRunnerContext } from 'src/engine/api/common/types/common-extended-query-runner-context.type'; import { type CommonGroupByOutputItem } from 'src/engine/api/common/types/common-group-by-output-item.type'; import { type GraphqlQuerySelectedFieldsResult } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-selected-fields/graphql-selected-fields.parser'; import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.types'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; +import { ProcessNestedRelationsHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-nested-relations.helper'; import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select'; -import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; -import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder'; import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; const GROUPS_LIMIT = 50; const RECORDS_PER_GROUP_LIMIT = 10; +const RELATIONS_PER_RECORD_LIMIT = 5; const SUB_QUERY_PREFIX = 'sub_query_'; +@Injectable() export class GroupByWithRecordsService { + @Inject() + protected readonly processNestedRelationsHelper: ProcessNestedRelationsHelper; + @Inject() + protected readonly commonResultGettersService: CommonResultGettersService; constructor() {} public async resolveWithRecords({ queryBuilderWithGroupBy, queryBuilderWithFiltersAndWithoutGroupBy, groupByDefinitions, - objectMetadataItemWithFieldMaps, selectedFieldsResult, - objectMetadataMaps, - repository, + queryRunnerContext, }: { queryBuilderWithGroupBy: WorkspaceSelectQueryBuilder; queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder; groupByDefinitions: GroupByDefinition[]; - objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; selectedFieldsResult: GraphqlQuerySelectedFieldsResult; - objectMetadataMaps: ObjectMetadataMaps; - repository: WorkspaceRepository; + queryRunnerContext: CommonExtendedQueryRunnerContext; }): Promise { const groupsResult = await queryBuilderWithGroupBy .limit(GROUPS_LIMIT) @@ -43,6 +49,15 @@ export class GroupByWithRecordsService { return []; } + const { + authContext, + workspaceDataSource, + rolePermissionConfig, + objectMetadataMaps, + objectMetadataItemWithFieldMaps, + repository, + } = queryRunnerContext; + const columnsToSelect = buildColumnsToSelect({ select: selectedFieldsResult.select, relations: selectedFieldsResult.relations, // TODO - not handled for now @@ -60,13 +75,36 @@ export class GroupByWithRecordsService { const recordsResult = await queryBuilderWithPartitionBy.getRawMany(); - return formatResultWithGroupByDimensionValues({ + if (isDefined(selectedFieldsResult.relations)) { + await this.processNestedRelationsHelper.processNestedRelations({ + objectMetadataMaps, + parentObjectMetadataItem: objectMetadataItemWithFieldMaps, + parentObjectRecords: recordsResult.flatMap((group) => group.records), + parentObjectRecordsAggregatedValues: {}, + relations: selectedFieldsResult.relations, + aggregate: selectedFieldsResult.aggregate, + limit: RELATIONS_PER_RECORD_LIMIT, + authContext, + workspaceDataSource, + rolePermissionConfig, + selectedFields: selectedFieldsResult.select, + }); + } + + return await formatResultWithGroupByDimensionValues({ groupsResult, recordsResult, groupByDefinitions, aggregateFieldNames: Object.keys(selectedFieldsResult.aggregate), objectMetadataItemWithFieldMaps, objectMetadataMaps, + processRecord: (record: ObjectRecord) => + this.commonResultGettersService.processRecord( + record, + objectMetadataItemWithFieldMaps.id, + objectMetadataMaps, + authContext.workspace.id, + ), }); } diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts index 0afb3c08e6..97288a1495 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts @@ -40,6 +40,8 @@ describe('basic group-by with records', () => { const testOpportunityId4 = randomUUID(); const testCompanyId1 = randomUUID(); const testCompanyId2 = randomUUID(); + const COMPANY_1_EMPLOYEES = 10; + const COMPANY_2_EMPLOYEES = 20; beforeAll(async () => { // Create test companies @@ -50,7 +52,8 @@ describe('basic group-by with records', () => { data: { id: testCompanyId1, name: 'Company 1', - employees: 10, + employees: COMPANY_1_EMPLOYEES, + createdAt: '2020-02-05T08:00:00.000Z', }, }), ); @@ -62,7 +65,8 @@ describe('basic group-by with records', () => { data: { id: testCompanyId2, name: 'Company 2', - employees: 20, + employees: COMPANY_2_EMPLOYEES, + createdAt: '2020-02-05T08:00:00.000Z', }, }), ); @@ -182,6 +186,10 @@ describe('basic group-by with records', () => { amount { amountMicros } + company { + id + employees + } } } } @@ -243,14 +251,23 @@ describe('basic group-by with records', () => { expect(thursdayNewGroup).toBeDefined(); expect(thursdayNewGroup.edges).toHaveLength(2); expect(thursdayNewGroup.sumAmountAmountMicros).toBe(5000000000000); - for (const edge of thursdayNewGroup.edges) { - expect(edge.node.stage).toBe('NEW'); - expect(edge.node.name).toBeDefined(); - expect( - edge.node.name === 'Opportunity 2' || - edge.node.name === 'Opportunity 3', - ).toBe(true); - } + const opportunity2Edge = thursdayNewGroup.edges.find( + (edge: any) => edge.node.name === 'Opportunity 2', + ).node; + const opportunity3Edge = thursdayNewGroup.edges.find( + (edge: any) => edge.node.name === 'Opportunity 3', + ).node; + + expect(opportunity2Edge.amount.amountMicros).toBe(2000000000000); + expect(opportunity2Edge.stage).toBe('NEW'); + expect(opportunity2Edge.name).toBe('Opportunity 2'); + expect(opportunity2Edge.company.id).toBe(testCompanyId1); + expect(opportunity2Edge.company.employees).toBe(COMPANY_1_EMPLOYEES); + expect(opportunity3Edge.amount.amountMicros).toBe(3000000000000); + expect(opportunity3Edge.stage).toBe('NEW'); + expect(opportunity3Edge.name).toBe('Opportunity 3'); + expect(opportunity3Edge.company.id).toBe(testCompanyId2); + expect(opportunity3Edge.company.employees).toBe(COMPANY_2_EMPLOYEES); const thursdayScreeningGroup = groups.find( (group: any) => @@ -260,11 +277,13 @@ describe('basic group-by with records', () => { expect(thursdayScreeningGroup).toBeDefined(); expect(thursdayScreeningGroup.edges).toHaveLength(1); - expect(thursdayScreeningGroup.edges[0].node.amount.amountMicros).toBe( - 4000000000000, - ); - expect(thursdayScreeningGroup.edges[0].node.stage).toBe('SCREENING'); - expect(thursdayScreeningGroup.edges[0].node.name).toBe('Opportunity 4'); + const opportunity4Edge = thursdayScreeningGroup.edges[0].node; + + expect(opportunity4Edge.amount.amountMicros).toBe(4000000000000); + expect(opportunity4Edge.stage).toBe('SCREENING'); + expect(opportunity4Edge.name).toBe('Opportunity 4'); + expect(opportunity4Edge.company.id).toBe(testCompanyId2); + expect(opportunity4Edge.company.employees).toBe(COMPANY_2_EMPLOYEES); expect(thursdayScreeningGroup.sumAmountAmountMicros).toBe(4000000000000); }); @@ -349,4 +368,93 @@ describe('basic group-by with records', () => { expect(thursdayNewGroup.groupByDimensionValues).toContain('Thursday'); expect(thursdayNewGroup.edges).toHaveLength(2); }); + + it('groups companies by employees with relations', async () => { + const response = await makeGraphqlAPIRequest({ + query: gql` + query CompaniesGroupBy( + $groupBy: [CompanyGroupByInput!] + $filter: CompanyFilterInput + ) { + companiesGroupBy(groupBy: $groupBy, filter: $filter) { + groupByDimensionValues + __typename + edges { + node { + name + opportunities { + edges { + node { + name + stage + } + } + } + } + } + } + } + `, + variables: { + groupBy: [ + { + employees: true, + }, + ], + filter: FILTER_2020, + }, + }); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + + const groups = response.body.data.companiesGroupBy; + + expect(groups).toHaveLength(2); + const company1Group = groups.find((group: any) => + group.groupByDimensionValues.includes(COMPANY_1_EMPLOYEES), + ); + + expect(company1Group).toBeDefined(); + expect(company1Group.edges).toHaveLength(1); + expect(company1Group.edges[0].node.name).toBe('Company 1'); + expect(company1Group.edges[0].node.opportunities.edges).toHaveLength(2); + const opportunity1Edge = + company1Group.edges[0].node.opportunities.edges.find( + (edge: any) => edge.node.name === 'Opportunity 1', + ).node; + + expect(opportunity1Edge.name).toBe('Opportunity 1'); + expect(opportunity1Edge.stage).toBe('NEW'); + const opportunity2Edge = + company1Group.edges[0].node.opportunities.edges.find( + (edge: any) => edge.node.name === 'Opportunity 2', + ).node; + + expect(opportunity2Edge.name).toBe('Opportunity 2'); + expect(opportunity2Edge.stage).toBe('NEW'); + + const company2Group = groups.find((group: any) => + group.groupByDimensionValues.includes(COMPANY_2_EMPLOYEES), + ); + + expect(company2Group).toBeDefined(); + expect(company2Group.edges).toHaveLength(1); + expect(company2Group.edges[0].node.name).toBe('Company 2'); + expect(company2Group.edges[0].node.opportunities.edges).toHaveLength(2); + const opportunity3Edge = + company2Group.edges[0].node.opportunities.edges.find( + (edge: any) => edge.node.name === 'Opportunity 3', + ).node; + + expect(opportunity3Edge.name).toBe('Opportunity 3'); + expect(opportunity3Edge.stage).toBe('NEW'); + const opportunity4Edge = + company2Group.edges[0].node.opportunities.edges.find( + (edge: any) => edge.node.name === 'Opportunity 4', + ).node; + + expect(opportunity4Edge.name).toBe('Opportunity 4'); + expect(opportunity4Edge.stage).toBe('SCREENING'); + }); });