diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index a60bf4ffb3..d84b46c79f 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -166,6 +166,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic groupByDefinitions, selectedFieldsResult: args.selectedFieldsResult, queryRunnerContext, + orderByForRecords: args.orderByForRecords ?? [], }); } diff --git a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts index 30b51d20cf..7419b03f81 100644 --- a/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts +++ b/packages/twenty-server/src/engine/api/common/types/common-query-args.type.ts @@ -64,6 +64,7 @@ export interface CreateOneQueryArgs { export interface GroupByQueryArgs { filter?: ObjectRecordFilter; orderBy?: OrderByWithGroupBy; + orderByForRecords?: ObjectRecordOrderBy; groupBy: ObjectRecordGroupBy; viewId?: string; includeRecords?: boolean; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts index df2242631a..917d7d0420 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/services/group-by-with-records.service.ts @@ -1,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; queryBuilderWithFiltersAndWithoutGroupBy: WorkspaceSelectQueryBuilder; groupByDefinitions: GroupByDefinition[]; selectedFieldsResult: GraphqlQuerySelectedFieldsResult; queryRunnerContext: CommonExtendedQueryRunnerContext; + orderByForRecords: ObjectRecordOrderBy; }): Promise { 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; columnsToSelect: Record; groupsResult: Array>; groupByDefinitions: GroupByDefinition[]; repository: WorkspaceRepository; + orderByForRecords: ObjectRecordOrderBy; + objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps; + objectMetadataMaps: ObjectMetadataMaps; }): WorkspaceSelectQueryBuilder { 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 diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts index 133edc6c70..5710f187b0 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/group-by-resolver.factory.ts @@ -59,6 +59,7 @@ export class GroupByResolverFactory totalCount: Number(group.totalCount ?? 0), hasNextPage: false, hasPreviousPage: false, + order: args.orderByForRecords ?? [], }); const { _records, ...groupWithoutRecords } = group; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index 2715f081ea..72d4ddd358 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -63,6 +63,7 @@ export interface GroupByResolverArgs { groupBy: ObjectRecordGroupBy; viewId?: string; orderBy?: OrderByWithGroupBy; + orderByForRecords?: ObjectRecordOrderBy; } export interface UpdateOneResolverArgs< diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index 8de1853a42..1a887145df 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -177,7 +177,11 @@ export const getResolverArgs = ( isNullable: true, isArray: true, }, - + orderByForRecords: { + kind: GqlInputTypeDefinitionKind.OrderBy, + isNullable: true, + isArray: true, + }, viewId: { type: UUIDScalarType, isNullable: true, diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts index 377db2581e..d8fe20944a 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-group-by.handler.ts @@ -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, diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts index 6fcb08148e..66c8c18dca 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/__tests__/parse-aggregate-fields-rest-request.util.spec.ts @@ -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({}); }); }); diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts index 53657bf37f..f0f24c8434 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/aggregate-fields-parser-utils/parse-aggregate-fields-rest-request.util.ts @@ -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"]`, diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts index deaa0a2be3..3340524102 100644 --- a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/parse-order-by-rest-request.util.ts @@ -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> = []; - 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); }; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/utils/parse-order-by-rest-request-common.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/utils/parse-order-by-rest-request-common.util.ts new file mode 100644 index 0000000000..f6030b7747 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-parser-utils/utils/parse-order-by-rest-request-common.util.ts @@ -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> = []; + 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); +}; diff --git a/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-for-records-rest-request.util.ts b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-for-records-rest-request.util.ts new file mode 100644 index 0000000000..4047f9324c --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/input-request-parsers/order-by-with-group-by-parser-utils/parse-order-by-for-records-rest-request.util.ts @@ -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); +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts index eddfb8b9e0..038bfc1808 100644 --- a/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts +++ b/packages/twenty-server/src/engine/workspace-manager/standard-objects-prefill-data/standard-objects-prefill-data.ts @@ -9,7 +9,6 @@ export const standardObjectsPrefillData = async ( dataSource: DataSource, schemaName: string, objectMetadataItems: ObjectMetadataEntity[], - featureFlags?: Record, ) => { dataSource.transaction(async (entityManager: EntityManager) => { await prefillCompanies(entityManager, schemaName); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts index 61d2c22f3f..7282f18fd6 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-manager.service.ts @@ -119,7 +119,6 @@ export class WorkspaceManagerService { this.coreDataSource, dataSourceMetadata.schema, createdObjectMetadata, - featureFlags, ); await prefillCoreViews({ diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts index 97288a1495..dc87e4275b 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-with-records-resolver.integration-spec.ts @@ -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'); + }); + }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts index 573439b1c5..a4b7367f1e 100644 --- a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts @@ -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 }), }, }); diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts new file mode 100644 index 0000000000..951fe9e674 --- /dev/null +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-group-by.integration-spec.ts @@ -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'); + }); + }); +});