[groupBy] Handle relations on groupBy with records (#15379)

Following PR https://github.com/twentyhq/twenty/pull/15307
This commit is contained in:
Marie
2025-10-27 16:14:02 +01:00
committed by GitHub
parent 1bf40d9dca
commit 0e7d18546d
4 changed files with 199 additions and 42 deletions
@@ -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,
});
}
@@ -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<string, unknown>[];
groupByDefinitions: GroupByDefinition[];
aggregateFieldNames: string[];
processRecord?: (record: ObjectRecord) => Promise<ObjectRecord>;
recordsResult?: Array<Record<string, unknown>>;
objectMetadataItemWithFieldMaps?: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps?: ObjectMetadataMaps;
}): CommonGroupByOutputItem[] => {
}): Promise<CommonGroupByOutputItem[]> => {
const formattedResult: CommonGroupByOutputItem[] = [];
const recordsByGroupKey = new Map<string, Array<Record<string, unknown>>>();
@@ -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<string, unknown>[] = (
(entry.records as Array<Record<string, unknown>>) ?? []
).map((record) => {
return formatResult(
record,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
});
const records = await Promise.all(
((entry.records as Array<Record<string, unknown>>) ?? []).map(
async (record) =>
await processRecord(
formatResult(
record,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
),
),
),
);
recordsByGroupKey.set(groupKey, records);
});
}
}
groupsResult.forEach((group) => {
@@ -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<ObjectLiteral>;
queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByDefinitions: GroupByDefinition[];
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
objectMetadataMaps: ObjectMetadataMaps;
repository: WorkspaceRepository<ObjectLiteral>;
queryRunnerContext: CommonExtendedQueryRunnerContext;
}): Promise<CommonGroupByOutputItem[]> {
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,
),
});
}
@@ -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');
});
});