[groupBy] Order by within records (#15404)

Closes https://github.com/twentyhq/core-team-issues/issues/1727

<img width="1415" height="625" alt="image"
src="https://github.com/user-attachments/assets/cd132582-4a95-463c-8946-87b77670e023"
/>
This commit is contained in:
Marie
2025-10-29 14:49:10 +01:00
committed by GitHub
parent ab10aa356d
commit 473efb5dc5
17 changed files with 603 additions and 81 deletions
@@ -166,6 +166,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
groupByDefinitions,
selectedFieldsResult: args.selectedFieldsResult,
queryRunnerContext,
orderByForRecords: args.orderByForRecords ?? [],
});
}
@@ -64,6 +64,7 @@ export interface CreateOneQueryArgs {
export interface GroupByQueryArgs {
filter?: ObjectRecordFilter;
orderBy?: OrderByWithGroupBy;
orderByForRecords?: ObjectRecordOrderBy;
groupBy: ObjectRecordGroupBy;
viewId?: string;
includeRecords?: boolean;
@@ -1,17 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import isEmpty from 'lodash.isempty';
import { ObjectRecord } from 'twenty-shared/types';
import { type ObjectLiteral } from 'typeorm';
import { ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
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 { GraphqlQueryParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser';
import { type GroupByDefinition } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-definition.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 { 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 { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -34,12 +40,14 @@ export class GroupByWithRecordsService {
groupByDefinitions,
selectedFieldsResult,
queryRunnerContext,
orderByForRecords,
}: {
queryBuilderWithGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder<ObjectLiteral>;
groupByDefinitions: GroupByDefinition[];
selectedFieldsResult: GraphqlQuerySelectedFieldsResult;
queryRunnerContext: CommonExtendedQueryRunnerContext;
orderByForRecords: ObjectRecordOrderBy;
}): Promise<CommonGroupByOutputItem[]> {
const groupsResult = await queryBuilderWithGroupBy
.limit(GROUPS_LIMIT)
@@ -60,7 +68,7 @@ export class GroupByWithRecordsService {
const columnsToSelect = buildColumnsToSelect({
select: selectedFieldsResult.select,
relations: selectedFieldsResult.relations, // TODO - not handled for now
relations: selectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
objectMetadataMaps: objectMetadataMaps,
});
@@ -71,6 +79,9 @@ export class GroupByWithRecordsService {
groupsResult,
groupByDefinitions,
repository,
orderByForRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
});
const recordsResult = await queryBuilderWithPartitionBy.getRawMany();
@@ -114,12 +125,18 @@ export class GroupByWithRecordsService {
groupsResult,
groupByDefinitions,
repository,
orderByForRecords,
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
}: {
queryBuilderForSubQuery: WorkspaceSelectQueryBuilder<ObjectLiteral>;
columnsToSelect: Record<string, boolean>;
groupsResult: Array<Record<string, unknown>>;
groupByDefinitions: GroupByDefinition[];
repository: WorkspaceRepository<ObjectLiteral>;
orderByForRecords: ObjectRecordOrderBy;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
objectMetadataMaps: ObjectMetadataMaps;
}): WorkspaceSelectQueryBuilder<ObjectLiteral> {
const groupByExpressions = groupByDefinitions
.map((def) => def.expression)
@@ -149,6 +166,19 @@ export class GroupByWithRecordsService {
.addSelect(`ROW_NUMBER() OVER (PARTITION BY ${groupByExpressions})`, 'rn')
.andWhere(groupConditions);
if (!isEmpty(orderByForRecords)) {
const graphqlQueryParser = new GraphqlQueryParser(
objectMetadataItemWithFieldMaps,
objectMetadataMaps,
);
graphqlQueryParser.applyOrderToBuilder(
subQuery,
orderByForRecords,
objectMetadataItemWithFieldMaps.nameSingular,
);
}
let mainQueryQueryBuilder = repository.createQueryBuilder();
const mainQuery = mainQueryQueryBuilder
@@ -59,6 +59,7 @@ export class GroupByResolverFactory
totalCount: Number(group.totalCount ?? 0),
hasNextPage: false,
hasPreviousPage: false,
order: args.orderByForRecords ?? [],
});
const { _records, ...groupWithoutRecords } = group;
@@ -63,6 +63,7 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
groupBy: ObjectRecordGroupBy;
viewId?: string;
orderBy?: OrderByWithGroupBy;
orderByForRecords?: ObjectRecordOrderBy;
}
export interface UpdateOneResolverArgs<
@@ -177,7 +177,11 @@ export const getResolverArgs = (
isNullable: true,
isArray: true,
},
orderByForRecords: {
kind: GqlInputTypeDefinitionKind.OrderBy,
isNullable: true,
isArray: true,
},
viewId: {
type: UUIDScalarType,
isNullable: true,
@@ -6,6 +6,7 @@ import { parseAggregateFieldsRestRequest } from 'src/engine/api/rest/input-reque
import { parseFilterRestRequest } from 'src/engine/api/rest/input-request-parsers/filter-parser-utils/parse-filter-rest-request.util';
import { parseGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-parser-utils/parse-group-by-rest-request.util';
import { parseIncludeRecordsSampleRestRequest } from 'src/engine/api/rest/input-request-parsers/group-by-with-records/parse-include-records-sample-rest-request.util';
import { parseOrderByForRecordsWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-for-records-rest-request.util';
import { parseOrderByWithGroupByRestRequest } from 'src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-with-group-by-rest-request.util';
import { parseViewIdRestRequest } from 'src/engine/api/rest/input-request-parsers/view-id-parser-utils/parse-view-id-rest-request.util';
import { AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
@@ -31,6 +32,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
objectMetadataMaps,
objectMetadataItemWithFieldMaps,
includeRecords,
orderByForRecords,
} = await this.parseRequestArgs(request);
return await this.commonGroupByQueryRunnerService.execute(
@@ -41,6 +43,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
groupBy,
selectedFields,
includeRecords,
orderByForRecords,
},
{
authContext,
@@ -58,6 +61,8 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
await this.buildCommonOptions(request);
const orderByWithGroupBy = parseOrderByWithGroupByRestRequest(request);
const orderByForRecordsWithGroupBy =
parseOrderByForRecordsWithGroupByRestRequest(request);
const filter = parseFilterRestRequest(request);
const viewId = parseViewIdRestRequest(request);
const groupBy = parseGroupByRestRequest(request);
@@ -82,6 +87,7 @@ export class RestApiGroupByHandler extends RestApiBaseHandler {
objectMetadataMaps,
filter,
orderBy: orderByWithGroupBy,
orderByForRecords: orderByForRecordsWithGroupBy,
viewId,
groupBy,
selectedFields,
@@ -62,16 +62,11 @@ describe('parseAggregateFieldsRestRequest', () => {
);
});
it('should throw if aggregate parameter is undefined', () => {
it('should early return 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,
),
);
expect(parseAggregateFieldsRestRequest(request)).toEqual({});
});
});
@@ -1,3 +1,5 @@
import { isDefined } from 'twenty-shared/utils';
import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type';
import {
RestInputRequestParserException,
@@ -10,6 +12,8 @@ export const parseAggregateFieldsRestRequest = (
): CommonSelectedFields => {
const aggregateFieldsQuery = request.query.aggregate;
if (!isDefined(aggregateFieldsQuery)) return {};
if (typeof aggregateFieldsQuery !== 'string') {
throw new RestInputRequestParserException(
`Invalid aggregate query parameter - should be a valid array of string - ex: ["countNotEmptyId", "countEmptyField"]`,
@@ -1,80 +1,14 @@
//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 { OrderByDirection } from 'twenty-shared/types';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
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 { parseOrderByRestRequestCommon } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/utils/parse-order-by-rest-request-common.util';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst;
export const parseOrderByRestRequest = (
request: AuthenticatedRequest,
): ObjectRecordOrderBy => {
const orderByQuery = request.query.order_by;
if (typeof orderByQuery !== 'string') {
return addDefaultOrderById([{}]);
}
//orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3
const orderByItems = orderByQuery.split(',');
let result: Array<Record<string, OrderByDirection>> = [];
let itemDirection = '';
let itemFields = '';
for (const orderByItem of orderByItems) {
// orderByItem -> field_1[AscNullsFirst]
if (orderByItem.includes('[') && orderByItem.includes(']')) {
const [fieldName, directionWithRightBracket] = orderByItem.split('[');
const direction = directionWithRightBracket.replace(']', '');
// fields -> [field_1] ; direction -> AscNullsFirst
if (!(direction in OrderByDirection)) {
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,
);
}
itemDirection = direction;
itemFields = fieldName;
} else {
// orderByItem -> field_3
itemDirection = DEFAULT_ORDER_DIRECTION;
itemFields = orderByItem;
}
let fieldResult = {};
itemFields
.split('.')
.reverse()
.forEach((field) => {
if (Object.keys(fieldResult).length) {
fieldResult = { [field]: fieldResult };
} else {
// @ts-expect-error legacy noImplicitAny
fieldResult[field] = itemDirection;
}
}, itemDirection);
const resultFields = Object.keys(fieldResult).map((key) => ({
// @ts-expect-error legacy noImplicitAny
[key]: fieldResult[key],
}));
result = [...result, ...resultFields];
}
return addDefaultOrderById(result);
return parseOrderByRestRequestCommon(orderByQuery);
};
@@ -0,0 +1,79 @@
//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 { OrderByDirection } from 'twenty-shared/types';
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import type { ParsedQs } from 'qs';
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';
const DEFAULT_ORDER_DIRECTION = OrderByDirection.AscNullsFirst;
export const parseOrderByRestRequestCommon = (
orderByQuery: string | string[] | ParsedQs | ParsedQs[] | undefined,
): ObjectRecordOrderBy => {
if (typeof orderByQuery !== 'string') {
return addDefaultOrderById([{}]);
}
//orderByQuery = field_1[AscNullsFirst],field_2[DescNullsLast],field_3
const orderByItems = orderByQuery.split(',');
let result: Array<Record<string, OrderByDirection>> = [];
let itemDirection = '';
let itemFields = '';
for (const orderByItem of orderByItems) {
// orderByItem -> field_1[AscNullsFirst]
if (orderByItem.includes('[') && orderByItem.includes(']')) {
const [fieldName, directionWithRightBracket] = orderByItem.split('[');
const direction = directionWithRightBracket.replace(']', '');
// fields -> [field_1] ; direction -> AscNullsFirst
if (!(direction in OrderByDirection)) {
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,
);
}
itemDirection = direction;
itemFields = fieldName;
} else {
// orderByItem -> field_3
itemDirection = DEFAULT_ORDER_DIRECTION;
itemFields = orderByItem;
}
let fieldResult = {};
itemFields
.split('.')
.reverse()
.forEach((field) => {
if (Object.keys(fieldResult).length) {
fieldResult = { [field]: fieldResult };
} else {
// @ts-expect-error legacy noImplicitAny
fieldResult[field] = itemDirection;
}
}, itemDirection);
const resultFields = Object.keys(fieldResult).map((key) => ({
// @ts-expect-error legacy noImplicitAny
[key]: fieldResult[key],
}));
result = [...result, ...resultFields];
}
return addDefaultOrderById(result);
};
@@ -0,0 +1,12 @@
import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { parseOrderByRestRequestCommon } from 'src/engine/api/rest/input-request-parsers/order-by-parser-utils/utils/parse-order-by-rest-request-common.util';
import { type AuthenticatedRequest } from 'src/engine/api/rest/types/authenticated-request';
export const parseOrderByForRecordsWithGroupByRestRequest = (
request: AuthenticatedRequest,
): ObjectRecordOrderBy | undefined => {
const orderByForRecordsWithGroupByQuery = request.query.order_by_for_records;
return parseOrderByRestRequestCommon(orderByForRecordsWithGroupByQuery);
};
@@ -9,7 +9,6 @@ export const standardObjectsPrefillData = async (
dataSource: DataSource,
schemaName: string,
objectMetadataItems: ObjectMetadataEntity[],
featureFlags?: Record<string, boolean>,
) => {
dataSource.transaction(async (entityManager: EntityManager) => {
await prefillCompanies(entityManager, schemaName);
@@ -119,7 +119,6 @@ export class WorkspaceManagerService {
this.coreDataSource,
dataSourceMetadata.schema,
createdObjectMetadata,
featureFlags,
);
await prefillCoreViews({
@@ -206,6 +206,9 @@ describe('basic group-by with records', () => {
stage: true,
},
],
orderByForRecords: {
name: 'AscNullsFirst',
},
filter: FILTER_2020,
},
});
@@ -218,7 +221,6 @@ describe('basic group-by with records', () => {
expect(groups).toBeDefined();
expect(groups).toHaveLength(3);
// Check that each group has the expected structure
groups.forEach((group: any) => {
expect(group.groupByDimensionValues).toBeDefined();
expect(Array.isArray(group.groupByDimensionValues)).toBe(true);
@@ -226,7 +228,6 @@ describe('basic group-by with records', () => {
expect(Array.isArray(group.edges)).toBe(true);
});
// Find specific groups and verify their content
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
@@ -457,4 +458,95 @@ describe('basic group-by with records', () => {
expect(opportunity4Edge.name).toBe('Opportunity 4');
expect(opportunity4Edge.stage).toBe('SCREENING');
});
describe('order by for records', () => {
const getQueryWithOrderByForRecords = (orderByForRecords: string) => {
return {
query: gql`
query OpportunitiesGroupBy(
$groupBy: [OpportunityGroupByInput!]
$filter: OpportunityFilterInput
$orderByForRecords: [OpportunityOrderByInput!]
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderByForRecords: $orderByForRecords
) {
groupByDimensionValues
__typename
edges {
node {
stage
name
}
}
}
}
`,
variables: {
groupBy: [
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
],
orderByForRecords: [
{
name: orderByForRecords,
},
],
filter: FILTER_2020,
},
};
};
it('sorts by name in ascending order', async () => {
const response = await makeGraphqlAPIRequest(
getQueryWithOrderByForRecords('AscNullsFirst'),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.edges).toHaveLength(2);
expect(thursdayNewGroup.edges[0].node.name).toBe('Opportunity 2');
expect(thursdayNewGroup.edges[1].node.name).toBe('Opportunity 3');
});
it('sorts by name in descending order', async () => {
const response = await makeGraphqlAPIRequest(
getQueryWithOrderByForRecords('DescNullsFirst'),
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
const groups = response.body.data.opportunitiesGroupBy;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.edges).toHaveLength(2);
expect(thursdayNewGroup.edges[0].node.name).toBe('Opportunity 3');
expect(thursdayNewGroup.edges[1].node.name).toBe('Opportunity 2');
});
});
});
@@ -4,6 +4,7 @@ import { capitalize } from 'twenty-shared/utils';
type GroupByOperationFactoryParams = {
objectMetadataSingularName: string;
objectMetadataPluralName: string;
orderByForRecords?: object[];
groupBy: object[];
filter?: object;
orderBy?: object[];
@@ -17,6 +18,7 @@ export const groupByOperationFactory = ({
groupBy,
filter = {},
orderBy = [],
orderByForRecords = [],
viewId,
gqlFields,
}: GroupByOperationFactoryParams) => ({
@@ -33,6 +35,7 @@ export const groupByOperationFactory = ({
groupBy,
filter,
orderBy,
orderByForRecords,
...(viewId && { viewId }),
},
});
@@ -0,0 +1,361 @@
import { randomUUID } from 'crypto';
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destroy-one-operation-factory.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
const OPPORTUNITY_GQL_FIELDS = `
id
stage
amount {
amountMicros
}
companyId
createdAt
closeDate
`;
// used not to mix records with the seeded ones
const FILTER_2020 =
"createdAt[gte]:'2020-01-01T00:00:00.000Z',createdAt[lte]:'2020-03-03T23:59:59.999Z'";
const AGGREGATE_FIELDS = '["maxAmountAmountMicros"]';
describe('REST API Core Group By endpoint', () => {
const testOpportunityId1 = randomUUID();
const testOpportunityId2 = randomUUID();
const testOpportunityId3 = randomUUID();
const testOpportunityId4 = randomUUID();
const testCompanyId1 = randomUUID();
const testCompanyId2 = randomUUID();
const COMPANY_1_EMPLOYEES = 10;
const COMPANY_2_EMPLOYEES = 20;
beforeAll(async () => {
// Create test companies
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId1,
name: 'Company 1',
employees: COMPANY_1_EMPLOYEES,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS,
data: {
id: testCompanyId2,
name: 'Company 2',
employees: COMPANY_2_EMPLOYEES,
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
// Create test opportunities with different stages and dates
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId1,
stage: 'NEW',
name: 'Opportunity 1',
amount: { amountMicros: 1000000000000 }, // 1000
companyId: testCompanyId1,
closeDate: '2025-02-05T08:00:00.000Z', // Wednesday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId2,
stage: 'NEW',
name: 'Opportunity 2',
amount: { amountMicros: 2000000000000 }, // 2000
companyId: testCompanyId1,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId3,
stage: 'NEW',
name: 'Opportunity 3',
amount: { amountMicros: 3000000000000 }, // 3000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
await makeGraphqlAPIRequest(
createOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: OPPORTUNITY_GQL_FIELDS,
data: {
id: testOpportunityId4,
stage: 'SCREENING',
name: 'Opportunity 4',
amount: { amountMicros: 4000000000000 }, // 4000
companyId: testCompanyId2,
closeDate: '2025-02-06T08:00:00.000Z', // Thursday
createdAt: '2020-02-05T08:00:00.000Z',
},
}),
);
});
afterAll(async () => {
// Cleanup created opportunities
for (const id of [
testOpportunityId1,
testOpportunityId2,
testOpportunityId3,
testOpportunityId4,
]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'opportunity',
gqlFields: 'id',
recordId: id,
}),
);
}
// Cleanup created companies
for (const id of [testCompanyId1, testCompanyId2]) {
await makeGraphqlAPIRequest(
destroyOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id',
recordId: id,
}),
);
}
});
it('groups by stage and closeDate with records', async () => {
// Add query parameters for group by
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
const response = await makeRestAPIRequest({
method: 'get',
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(FILTER_2020)}&include_records_sample=true`,
body: {},
});
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
expect(groups).toBeDefined();
expect(groups).toHaveLength(3);
groups.forEach((group: any) => {
expect(group.groupByDimensionValues).toBeDefined();
expect(Array.isArray(group.groupByDimensionValues)).toBe(true);
expect(group.records).toBeDefined();
expect(Array.isArray(group.records)).toBe(true);
});
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup).toBeDefined();
expect(wednesdayNewGroup.maxAmountAmountMicros).toBe('1000000000000');
expect(wednesdayNewGroup.records).toHaveLength(1);
expect(wednesdayNewGroup.records[0].name).toBe('Opportunity 1');
expect(wednesdayNewGroup.records[0].stage).toBe('NEW');
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.maxAmountAmountMicros).toBe('3000000000000');
const opportunity2Record = thursdayNewGroup.records.find(
(record: any) => record.name === 'Opportunity 2',
);
const opportunity3Record = thursdayNewGroup.records.find(
(record: any) => record.name === 'Opportunity 3',
);
expect(opportunity2Record.stage).toBe('NEW');
expect(opportunity2Record.name).toBe('Opportunity 2');
expect(opportunity2Record.companyId).toBe(testCompanyId1);
expect(opportunity3Record.stage).toBe('NEW');
expect(opportunity3Record.name).toBe('Opportunity 3');
expect(opportunity3Record.companyId).toBe(testCompanyId2);
const thursdayScreeningGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('SCREENING'),
);
expect(thursdayScreeningGroup).toBeDefined();
expect(thursdayScreeningGroup.records).toHaveLength(1);
const opportunity4Record = thursdayScreeningGroup.records[0];
expect(opportunity4Record.stage).toBe('SCREENING');
expect(opportunity4Record.name).toBe('Opportunity 4');
expect(opportunity4Record.companyId).toBe(testCompanyId2);
expect(thursdayScreeningGroup.maxAmountAmountMicros).toBe('4000000000000');
});
it('groups by stage and closeDate with records and filters', async () => {
// Test with filter to only include NEW stage opportunities
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
const filterQuery = `${FILTER_2020},stage[eq]:'NEW'`;
const response = await makeRestAPIRequest({
method: 'get',
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&aggregate=${encodeURIComponent(AGGREGATE_FIELDS)}&filter=${encodeURIComponent(filterQuery)}&include_records_sample=true`,
body: {},
});
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
expect(groups).toHaveLength(2);
const wednesdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Wednesday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(wednesdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(wednesdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(wednesdayNewGroup.groupByDimensionValues).toContain('Wednesday');
expect(wednesdayNewGroup.records).toHaveLength(1);
expect(wednesdayNewGroup.records[0].stage).toBe('NEW');
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup.groupByDimensionValues).toHaveLength(2);
expect(thursdayNewGroup.groupByDimensionValues).toContain('NEW');
expect(thursdayNewGroup.groupByDimensionValues).toContain('Thursday');
expect(thursdayNewGroup.records).toHaveLength(2);
});
describe('order by for records', () => {
const getGroupByRequestWithOrderByForRecords = (
orderByForRecords: string,
) => {
const groupByQuery = JSON.stringify([
{
closeDate: {
granularity: 'DAY_OF_THE_WEEK',
},
},
{
stage: true,
},
]);
return {
method: 'get' as const,
path: `/opportunities/groupBy?group_by=${encodeURIComponent(groupByQuery)}&filter=${encodeURIComponent(FILTER_2020)}&order_by_for_records=${encodeURIComponent(`name[${orderByForRecords}]`)}&include_records_sample=true`,
body: {},
};
};
it('sorts by name in ascending order', async () => {
const response = await makeRestAPIRequest(
getGroupByRequestWithOrderByForRecords('AscNullsFirst'),
);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.records[0].name).toBe('Opportunity 2');
expect(thursdayNewGroup.records[1].name).toBe('Opportunity 3');
});
it('sorts by name in descending order', async () => {
const response = await makeRestAPIRequest(
getGroupByRequestWithOrderByForRecords('DescNullsFirst'),
);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
const groups = response.body;
const thursdayNewGroup = groups.find(
(group: any) =>
group.groupByDimensionValues.includes('Thursday') &&
group.groupByDimensionValues.includes('NEW'),
);
expect(thursdayNewGroup).toBeDefined();
expect(thursdayNewGroup.records).toHaveLength(2);
expect(thursdayNewGroup.records[0].name).toBe('Opportunity 3');
expect(thursdayNewGroup.records[1].name).toBe('Opportunity 2');
});
});
});