Common api - chores (#17051)

Remove refacto-common TODO

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Etienne
2026-01-12 12:37:29 +01:00
committed by GitHub
parent 7735a0fc7d
commit 238e6d5cda
16 changed files with 329 additions and 149 deletions
@@ -67,7 +67,6 @@ export class RestApiCoreController {
res.status(201).send(result);
}
//TODO: Refacto-common - Document this endpoint
@Get('*path/groupBy')
async handleApiGroupBy(
@Req() request: AuthenticatedRequest,
@@ -27,6 +27,7 @@ import {
import {
computeBatchPath,
computeDuplicatesResultPath,
computeGroupByResultPath,
computeManyResultPath,
computeMergeManyResultPath,
computeRestoreManyResultPath,
@@ -161,6 +162,11 @@ export class OpenApiService {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
paths[`/${item.namePlural}/groupBy`] = computeGroupByResultPath(
item,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
);
return paths;
}, schema.paths as OpenAPIV3_1.PathsObject);
@@ -60,8 +60,8 @@ describe('computeParameters', () => {
expect(computeDepthParameters()).toEqual({
name: 'depth',
in: 'query',
description: `Determines the level of nested related objects to include in the response.
- 0: Primary object only
description: `Determines the level of nested related objects to include in the response.
- 0: Primary object only
- 1: Primary object + direct relations`,
required: false,
schema: {
@@ -77,7 +77,7 @@ describe('computeParameters', () => {
expect(computeFilterParameters()).toEqual({
name: 'filter',
in: 'query',
description: `Format: field[COMPARATOR]:value,field2[COMPARATOR]:value2
description: `Format: field[COMPARATOR]:value,field2[COMPARATOR]:value2
Refer to the filter section at the top of the page for more details.`,
required: false,
schema: {
@@ -7,15 +7,20 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
import { generateRandomFieldValue } from 'src/engine/core-modules/open-api/utils/generate-random-field-value.util';
import {
computeAggregateParameters,
computeDepthParameters,
computeEndingBeforeParameters,
computeFilterParameters,
computeGroupByParameters,
computeIdPathParameter,
computeIncludeRecordsSampleParameters,
computeLimitParameters,
computeOrderByForRecordsParameters,
computeOrderByParameters,
computeSoftDeleteParameters,
computeStartingAfterParameters,
computeUpsertParameters,
computeViewIdParameters,
} from 'src/engine/core-modules/open-api/utils/parameters.utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -277,6 +282,11 @@ export const computeParameterComponents = (
softDelete: computeSoftDeleteParameters(),
orderBy: computeOrderByParameters(),
limit: computeLimitParameters(fromMetadata),
groupBy: computeGroupByParameters(),
viewId: computeViewIdParameters(),
aggregate: computeAggregateParameters(),
includeRecordsSample: computeIncludeRecordsSampleParameters(),
orderByForRecords: computeOrderByForRecordsParameters(),
};
};
@@ -49,8 +49,8 @@ export const computeDepthParameters = (): OpenAPIV3_1.ParameterObject => {
return {
name: 'depth',
in: 'query',
description: `Determines the level of nested related objects to include in the response.
- 0: Primary object only
description: `Determines the level of nested related objects to include in the response.
- 0: Primary object only
- 1: Primary object + direct relations`,
required: false,
schema: {
@@ -93,7 +93,7 @@ export const computeFilterParameters = (): OpenAPIV3_1.ParameterObject => {
return {
name: 'filter',
in: 'query',
description: `Format: field[COMPARATOR]:value,field2[COMPARATOR]:value2
description: `Format: field[COMPARATOR]:value,field2[COMPARATOR]:value2
Refer to the filter section at the top of the page for more details.`,
required: false,
schema: {
@@ -157,3 +157,98 @@ export const computeIdPathParameter = (): OpenAPIV3_1.ParameterObject => {
},
};
};
export const computeGroupByParameters = (): OpenAPIV3_1.ParameterObject => {
return {
name: 'group_by',
in: 'query',
description: `Array of fields to group by. Each element can specify a field and optionally a subfield or granularity for date fields.`,
required: true,
schema: {
type: 'string',
},
examples: {
simple: {
value: '[{"updatedAt": true}]',
summary: 'Group by a single field',
},
subfield: {
value: '[{"assignee": {"name": true}}]',
summary: 'Group by a relation field subfield',
},
dateGranularity: {
value: '[{"createdAt": {"granularity": "MONTH"}}]',
summary: 'Group by date with granularity (DAY, WEEK, MONTH, YEAR)',
},
},
};
};
export const computeViewIdParameters = (): OpenAPIV3_1.ParameterObject => {
return {
name: 'view_id',
in: 'query',
description: 'View ID to apply filters from.',
required: false,
schema: {
type: 'string',
format: 'uuid',
},
};
};
export const computeIncludeRecordsSampleParameters =
(): OpenAPIV3_1.ParameterObject => {
return {
name: 'include_records_sample',
in: 'query',
description:
'If true, includes a sample of records for each group in the response.',
required: false,
schema: {
type: 'boolean',
default: false,
},
};
};
export const computeAggregateParameters = (): OpenAPIV3_1.ParameterObject => {
return {
name: 'aggregate',
in: 'query',
description: `Array of aggregate operations to compute for each group.`,
required: false,
schema: {
type: 'string',
},
examples: {
count: {
value: '["countNotEmptyId"]',
summary: 'Count non-empty IDs in each group',
},
multiple: {
value: '["countNotEmptyId", "sumAmount"]',
summary: 'Multiple aggregate operations',
},
},
};
};
export const computeOrderByForRecordsParameters =
(): OpenAPIV3_1.ParameterObject => {
return {
name: 'order_by_for_records',
in: 'query',
description: `Order by clause for records within each group. Only applicable when include_records_sample is true.`,
required: false,
schema: {
type: 'string',
},
examples: {
simple: {
value: 'createdAt',
summary: 'Order records by createdAt',
},
},
};
};
@@ -16,6 +16,7 @@ import {
getFindDuplicatesResponse200,
getFindManyResponse200,
getFindOneResponse200,
getGroupByResponse200,
getJsonResponse,
getMergeManyResponse200,
getRestoreManyResponse200,
@@ -325,3 +326,37 @@ export const computeMergeManyResultPath = (
},
} as OpenAPIV3_1.PathItemObject;
};
export const computeGroupByResultPath = (
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
_flatObjectMetadataMaps: Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
>['flatObjectMetadataMaps'],
_flatFieldMetadataMaps: Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps'
>['flatFieldMetadataMaps'],
): OpenAPIV3_1.PathItemObject => {
return {
get: {
tags: [item.namePlural],
summary: `Group By ${item.namePlural}`,
description: `Groups **${item.namePlural}** by specified fields and optionally computes aggregate values for each group.`,
operationId: `groupBy${capitalize(item.namePlural)}`,
parameters: [
{ $ref: '#/components/parameters/groupBy' },
{ $ref: '#/components/parameters/filter' },
{ $ref: '#/components/parameters/orderBy' },
{ $ref: '#/components/parameters/limit' },
{ $ref: '#/components/parameters/viewId' },
{ $ref: '#/components/parameters/aggregate' },
{ $ref: '#/components/parameters/includeRecordsSample' },
{ $ref: '#/components/parameters/orderByForRecords' },
],
responses: {
'200': getGroupByResponse200(item),
},
},
} as OpenAPIV3_1.PathItemObject;
};
@@ -468,3 +468,57 @@ export const getMergeManyResponse200 = (
},
};
};
export const getGroupByResponse200 = (
item: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>,
) => {
const schemaRef = `#/components/schemas/${capitalize(
item.nameSingular,
)}ForResponse`;
return {
description: 'Successful operation',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
data: {
type: 'object',
properties: {
[`${item.namePlural}GroupBy`]: {
type: 'array',
items: {
type: 'object',
properties: {
groupByDimensionValues: {
type: 'array',
description:
'Array of values representing each dimension in the group',
items: {
type: 'string',
},
},
records: {
type: 'array',
description:
'Sample of records for this group (only present when include_records_sample is true)',
items: {
$ref: schemaRef,
},
},
},
additionalProperties: {
type: 'number',
description: 'Aggregate values (e.g., countNotEmptyId)',
},
},
},
},
},
},
},
},
},
};
};
@@ -1,13 +1,16 @@
import { failingFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant';
import { successfulFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant';
import { testGqlFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-failing-scenario.util';
import { testGqlSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util';
import { testRestFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-failing-scenario.util';
import { testRestSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-successful-scenario.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
import { FieldMetadataType } from 'twenty-shared/types';
const FIELD_METADATA_TYPE = FieldMetadataType.ARRAY;
// const failingTestCases =
// failingFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
const failingTestCases =
failingFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
const successfulTestCases =
successfulFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
@@ -36,43 +39,42 @@ describe(`Filter args validation - ${FIELD_METADATA_TYPE}`, () => {
]);
});
// describe('Gql filter input - failure', () => {
// it.each(
// failingTestCases.map((testCase) => ({
// ...testCase,
// stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
// })),
// )(
// `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
// async ({ gqlFilterInput: filter, gqlErrorMessage: errorMessage }) => {
// await testGqlFailingScenario(
// objectMetadataSingularName,
// objectMetadataPluralName,
// filter,
// errorMessage,
// );
// },
// );
// });
describe('Gql filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
...testCase,
stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
})),
)(
`${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
async ({ gqlFilterInput: filter, gqlErrorMessage: errorMessage }) => {
await testGqlFailingScenario(
objectMetadataSingularName,
objectMetadataPluralName,
filter,
errorMessage,
);
},
);
});
// // TODO : Refacto-common - Uncomment this
// describe('Rest filter input - failure', () => {
// it.each(
// failingTestCases.map((testCase) => ({
// ...testCase,
// stringifiedFilter: JSON.stringify(testCase.restFilterInput),
// })),
// )(
// `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
// async ({ restFilterInput: filter, restErrorMessage: errorMessage }) => {
// await testRestFailingScenario(
// objectMetadataPluralName,
// filter,
// errorMessage,
// );
// },
// );
// });
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
...testCase,
stringifiedFilter: JSON.stringify(testCase.restFilterInput),
})),
)(
`${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
async ({ restFilterInput: filter, restErrorMessage: errorMessage }) => {
await testRestFailingScenario(
objectMetadataPluralName,
filter,
errorMessage,
);
},
);
});
describe('Gql filter input - success', () => {
it.each(
@@ -1,12 +1,15 @@
import { failingFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/failing-filter-input-by-field-metadata-type.constant';
import { successfulFilterInputByFieldMetadataType } from 'test/integration/graphql/suites/inputs-validation/filter-validation/constants/successful-filter-input-by-field-metadata-type.constant';
import { testGqlFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-failing-scenario.util';
import { testGqlSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-gql-successful-scenario.util';
import { testRestFailingScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-failing-scenario.util';
import { testRestSuccessfulScenario } from 'test/integration/graphql/suites/inputs-validation/filter-validation/utils/test-rest-successful-scenario.util';
import { destroyManyObjectsMetadata } from 'test/integration/graphql/suites/inputs-validation/utils/destroy-many-objects-metadata';
import { setupTestObjectsWithAllFieldTypes } from 'test/integration/graphql/suites/inputs-validation/utils/setup-test-objects-with-all-field-types.util';
import { FieldMetadataType } from 'twenty-shared/types';
const FIELD_METADATA_TYPE = FieldMetadataType.BOOLEAN;
// const failingTestCases =
// failingFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
const failingTestCases =
failingFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
const successfulTestCases =
successfulFilterInputByFieldMetadataType[FIELD_METADATA_TYPE];
@@ -35,43 +38,42 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
]);
});
// describe('Gql filter input - failure', () => {
// it.each(
// failingTestCases.map((testCase) => ({
// ...testCase,
// stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
// })),
// )(
// `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
// async ({ gqlFilterInput: filter, gqlErrorMessage: errorMessage }) => {
// await testGqlFailingScenario(
// objectMetadataSingularName,
// objectMetadataPluralName,
// filter,
// errorMessage,
// );
// },
// );
// });
describe('Gql filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
...testCase,
stringifiedFilter: JSON.stringify(testCase.gqlFilterInput),
})),
)(
`${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
async ({ gqlFilterInput: filter, gqlErrorMessage: errorMessage }) => {
await testGqlFailingScenario(
objectMetadataSingularName,
objectMetadataPluralName,
filter,
errorMessage,
);
},
);
});
// // TODO : Refacto-common - Uncomment this
// describe('Rest filter input - failure', () => {
// it.each(
// failingTestCases.map((testCase) => ({
// ...testCase,
// stringifiedFilter: JSON.stringify(testCase.restFilterInput),
// })),
// )(
// `${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
// async ({ restFilterInput: filter, restErrorMessage: errorMessage }) => {
// await testRestFailingScenario(
// objectMetadataPluralName,
// filter,
// errorMessage,
// );
// },
// );
// });
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
...testCase,
stringifiedFilter: JSON.stringify(testCase.restFilterInput),
})),
)(
`${FIELD_METADATA_TYPE} field type - should fail with filter : $stringifiedFilter`,
async ({ restFilterInput: filter, restErrorMessage: errorMessage }) => {
await testRestFailingScenario(
objectMetadataPluralName,
filter,
errorMessage,
);
},
);
});
describe('Gql filter input - success', () => {
it.each(
@@ -197,33 +197,25 @@ export const failingFilterInputByFieldMetadataType: {
},
],
[FieldMetadataType.BOOLEAN]: [
// {
// gqlFilterInput: { booleanField: { eq: 'not-a-boolean' } },
// gqlErrorMessage:
// 'Boolean cannot represent a non boolean value: "not-a-boolean"',
// // TODO - fix this, should throw an error
// // restFilterInput: 'booleanField[eq]:"not-a-boolean"',
// // restErrorMessage: 'invalid input syntax for type boolean',
// },
// {
// gqlFilterInput: { booleanField: { eq: [] } },
// gqlErrorMessage: 'Boolean cannot represent a non boolean value: []',
// // TODO - fix this, should throw an error
// // restFilterInput: 'booleanField[eq]:"[]"',
// // restErrorMessage: 'invalid input syntax for type boolean',
// },
// {
// gqlFilterInput: { booleanField: { eq: 2 } },
// gqlErrorMessage: 'Boolean cannot represent a non boolean value: 2',
// // TODO - fix this, should throw an error
// // restFilterInput: 'booleanField[eq]:2',
// // restErrorMessage: 'invalid input syntax for type boolean',
// },
// TODO - fix this, should throw an error
// {
// gqlFilterInput: { booleanField: { eq: null } },
// gqlErrorMessage: 'Boolean cannot represent a non boolean value: null',
// },
{
gqlFilterInput: { booleanField: { eq: 'not-a-boolean' } },
gqlErrorMessage:
'Boolean cannot represent a non boolean value: "not-a-boolean"',
restFilterInput: 'booleanField[eq]:"not-a-boolean"',
restErrorMessage: 'invalid input syntax for type boolean',
},
{
gqlFilterInput: { booleanField: { eq: [] } },
gqlErrorMessage: 'Boolean cannot represent a non boolean value: []',
restFilterInput: 'booleanField[eq]:"[]"',
restErrorMessage: 'invalid input syntax for type boolean',
},
{
gqlFilterInput: { booleanField: { eq: 2 } },
gqlErrorMessage: 'Boolean cannot represent a non boolean value: 2',
restFilterInput: 'booleanField[eq]:2',
restErrorMessage: 'invalid input syntax for type boolean',
},
],
[FieldMetadataType.NUMBER]: [
// {
@@ -403,38 +395,29 @@ export const failingFilterInputByFieldMetadataType: {
// },
],
[FieldMetadataType.ARRAY]: [
// {
// gqlFilterInput: { arrayField: { containsIlike: {} } },
// gqlErrorMessage: 'cannot represent a non string value',
// // TODO - fix this ? containsIlike not existing for rest
// // restFilterInput: 'arrayField[containsIlike]:"{}"',
// // restErrorMessage: '',
// },
// {
// gqlFilterInput: { arrayField: { containsIlike: [] } },
// gqlErrorMessage: 'cannot represent a non string value',
// // TODO - fix this ? containsIlike not existing for rest
// // restFilterInput: 'arrayField[containsIlike]:"[]"',
// // restErrorMessage: '',
// },
// {
// gqlFilterInput: { arrayField: { containsIlike: true } },
// gqlErrorMessage: 'cannot represent a non string value',
// // TODO - fix this ? containsIlike not existing for rest
// // restFilterInput: 'arrayField[containsIlike]:"true"',
// // restErrorMessage: '',
// },
// {
// gqlFilterInput: { arrayField: { containsIlike: 2 } },
// gqlErrorMessage: 'cannot represent a non string value',
// // TODO - fix this ? containsIlike not existing for rest
// // restFilterInput: 'arrayField[containsIlike]:2',
// // restErrorMessage: '',
// },
// TODO - ensure it should throw
// {
// gqlFilterInput: { arrayField: { containsIlike: null } },
// gqlErrorMessage: 'cannot represent a non string value',
// },
{
gqlFilterInput: { arrayField: { containsIlike: {} } },
gqlErrorMessage: 'cannot represent a non string value',
restFilterInput: 'arrayField[containsAny]:"{}"',
restErrorMessage: 'array value expected',
},
{
gqlFilterInput: { arrayField: { containsIlike: [] } },
gqlErrorMessage: 'cannot represent a non string value',
restFilterInput: 'arrayField[containsAny]:"[]"',
restErrorMessage: 'array value expected',
},
{
gqlFilterInput: { arrayField: { containsIlike: true } },
gqlErrorMessage: 'cannot represent a non string value',
restFilterInput: 'arrayField[containsAny]:"true"',
restErrorMessage: 'array value expected',
},
{
gqlFilterInput: { arrayField: { containsIlike: 2 } },
gqlErrorMessage: 'cannot represent a non string value',
restFilterInput: 'arrayField[containsAny]:2',
restErrorMessage: 'array value expected',
},
],
};
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({
@@ -58,7 +58,6 @@ describe(`Filter input validation - ${FIELD_METADATA_TYPE}`, () => {
);
});
// TODO : Refacto-common - Uncomment this
describe('Rest filter input - failure', () => {
it.each(
failingTestCases.map((testCase) => ({