[groupBy] Handle relations on groupBy with records (#15379)
Following PR https://github.com/twentyhq/twenty/pull/15307
This commit is contained in:
+1
-4
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-14
@@ -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) => {
|
||||
|
||||
+47
-9
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+123
-15
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user