[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
@@ -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);
};