[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:
+1
@@ -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;
|
||||
|
||||
+31
-1
@@ -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
|
||||
|
||||
+1
@@ -59,6 +59,7 @@ export class GroupByResolverFactory
|
||||
totalCount: Number(group.totalCount ?? 0),
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
order: args.orderByForRecords ?? [],
|
||||
});
|
||||
|
||||
const { _records, ...groupWithoutRecords } = group;
|
||||
|
||||
+1
@@ -63,6 +63,7 @@ export interface GroupByResolverArgs<Filter = ObjectRecordFilter> {
|
||||
groupBy: ObjectRecordGroupBy;
|
||||
viewId?: string;
|
||||
orderBy?: OrderByWithGroupBy;
|
||||
orderByForRecords?: ObjectRecordOrderBy;
|
||||
}
|
||||
|
||||
export interface UpdateOneResolverArgs<
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+2
-7
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -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"]`,
|
||||
|
||||
+2
-68
@@ -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);
|
||||
};
|
||||
|
||||
+79
@@ -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);
|
||||
};
|
||||
+12
@@ -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);
|
||||
};
|
||||
-1
@@ -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({
|
||||
|
||||
+94
-2
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+3
@@ -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 }),
|
||||
},
|
||||
});
|
||||
|
||||
+361
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user