Fix REST API filters (#12929)
# Introduction close https://github.com/twentyhq/twenty/issues/12921 ### Done here: - Removed [check-order-by.utils.ts](https://github.com/twentyhq/twenty/pull/12929/files#diff-d044effc0b77b3b67523595ce0febd786d3a0fd74ae905ce2efc349134d7c7d0) that was a duplicated - new debug entry `twenty-server` entrypoint - fixed the fields name computation in case of a relation field metadata type - Updated and refactored coverage both unit and integration 
This commit is contained in:
21
.vscode/launch.json
vendored
21
.vscode/launch.json
vendored
@ -82,6 +82,27 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"NODE_ENV": "test"
|
"NODE_ENV": "test"
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "twenty-server - debug unit test file (to launch with test file open)",
|
||||||
|
"runtimeExecutable": "npx",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"nx",
|
||||||
|
"run",
|
||||||
|
"twenty-server:jest",
|
||||||
|
"--",
|
||||||
|
"--config",
|
||||||
|
"./jest.config.ts",
|
||||||
|
"${relativeFile}"
|
||||||
|
],
|
||||||
|
"cwd": "${workspaceFolder}/packages/twenty-server",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "test"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`checkFields should accept valid relation field name 1`] = `"field 'pointOfContact' does not exist in 'opportunity' object"`;
|
||||||
|
|
||||||
|
exports[`checkFields should reject invalid field name 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
|
||||||
|
|
||||||
|
exports[`checkFields should reject mix of valid and invalid field names 1`] = `"field 'wrongField' does not exist in 'opportunity' object"`;
|
||||||
@ -1,76 +1,63 @@
|
|||||||
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
|
import { EachTestingContext } from 'twenty-shared/testing';
|
||||||
|
|
||||||
import {
|
import { OPPORTUNITY_WITH_FIELDS_MAPS } from 'src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock';
|
||||||
fieldNumberMock,
|
|
||||||
objectMetadataItemMock,
|
|
||||||
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
|
|
||||||
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
|
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
|
||||||
import { checkArrayFields } from 'src/engine/api/rest/core/query-builder/utils/check-order-by.utils';
|
|
||||||
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
|
|
||||||
|
|
||||||
describe('checkFields', () => {
|
describe('checkFields', () => {
|
||||||
const completeFieldNumberMock: FieldMetadataInterface = {
|
const testCases: EachTestingContext<{
|
||||||
id: 'field-number-id',
|
fields: string[];
|
||||||
type: fieldNumberMock.type,
|
shouldThrow?: boolean;
|
||||||
name: fieldNumberMock.name,
|
}>[] = [
|
||||||
label: 'Field Number',
|
{
|
||||||
objectMetadataId: 'object-metadata-id',
|
title: 'should accept valid join column id',
|
||||||
isNullable: fieldNumberMock.isNullable,
|
context: {
|
||||||
defaultValue: fieldNumberMock.defaultValue,
|
fields: ['pointOfContactId'],
|
||||||
isLabelSyncedWithName: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const fieldsById: FieldMetadataMap = {
|
|
||||||
'field-number-id': completeFieldNumberMock,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockObjectMetadataWithFieldMaps = {
|
|
||||||
...objectMetadataItemMock,
|
|
||||||
fieldsById,
|
|
||||||
fieldIdByName: {
|
|
||||||
[completeFieldNumberMock.name]: completeFieldNumberMock.id,
|
|
||||||
},
|
},
|
||||||
fieldIdByJoinColumnName: {},
|
},
|
||||||
indexMetadatas: [],
|
{
|
||||||
};
|
title: 'should accept valid relation field name',
|
||||||
|
context: {
|
||||||
|
shouldThrow: true,
|
||||||
|
fields: ['pointOfContact'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'should accept valid field name',
|
||||||
|
context: {
|
||||||
|
fields: ['position'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'should reject invalid field name',
|
||||||
|
context: {
|
||||||
|
fields: ['wrongField'],
|
||||||
|
shouldThrow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'should reject mix of valid and invalid field names',
|
||||||
|
context: {
|
||||||
|
fields: ['position', 'wrongField'],
|
||||||
|
shouldThrow: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'should accept composite field',
|
||||||
|
context: {
|
||||||
|
fields: ['source'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
it('should check field types', () => {
|
it.each(testCases)('$title', ({ context }) => {
|
||||||
|
if (context.shouldThrow) {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
checkFields(mockObjectMetadataWithFieldMaps, ['fieldNumber']),
|
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
|
||||||
|
).toThrowErrorMatchingSnapshot();
|
||||||
|
} else {
|
||||||
|
expect(() =>
|
||||||
|
checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields),
|
||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
|
}
|
||||||
expect(() =>
|
|
||||||
checkFields(mockObjectMetadataWithFieldMaps, ['wrongField']),
|
|
||||||
).toThrow();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
checkFields(mockObjectMetadataWithFieldMaps, [
|
|
||||||
'fieldNumber',
|
|
||||||
'wrongField',
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should check field types from array of fields', () => {
|
|
||||||
expect(() =>
|
|
||||||
checkArrayFields(mockObjectMetadataWithFieldMaps, [
|
|
||||||
{ fieldNumber: undefined },
|
|
||||||
]),
|
|
||||||
).not.toThrow();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
checkArrayFields(mockObjectMetadataWithFieldMaps, [
|
|
||||||
{ wrongField: undefined },
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
checkArrayFields(mockObjectMetadataWithFieldMaps, [
|
|
||||||
{ fieldNumber: undefined },
|
|
||||||
{ wrongField: undefined },
|
|
||||||
]),
|
|
||||||
).toThrow();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,463 @@
|
|||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
|
||||||
|
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
|
||||||
|
|
||||||
|
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||||
|
|
||||||
|
export const OPPORTUNITY_WITH_FIELDS_MAPS = {
|
||||||
|
id: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
nameSingular: 'opportunity',
|
||||||
|
namePlural: 'opportunities',
|
||||||
|
labelSingular: 'Opportunity',
|
||||||
|
labelPlural: 'Opportunities',
|
||||||
|
description: 'An opportunity',
|
||||||
|
icon: 'IconTargetArrow',
|
||||||
|
targetTableName: 'DEPRECATED',
|
||||||
|
isCustom: false,
|
||||||
|
isRemote: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isAuditLogged: true,
|
||||||
|
isSearchable: true,
|
||||||
|
labelIdentifierFieldMetadataId: '20202020-c2f1-4435-adca-22931f8b41b6',
|
||||||
|
imageIdentifierFieldMetadataId: null,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
indexMetadatas: [], // unused
|
||||||
|
fieldsById: {
|
||||||
|
'20202020-c2f1-4435-adca-22931f8b41b6': {
|
||||||
|
id: '20202020-c2f1-4435-adca-22931f8b41b6',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.TEXT,
|
||||||
|
name: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
defaultValue: "''",
|
||||||
|
description: 'The opportunity name',
|
||||||
|
icon: 'IconTargetArrow',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-5eef-417a-b517-ebeedaa8e10b': {
|
||||||
|
id: '20202020-5eef-417a-b517-ebeedaa8e10b',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.CURRENCY,
|
||||||
|
name: 'amount',
|
||||||
|
label: 'Amount',
|
||||||
|
defaultValue: { amountMicros: null, currencyCode: "''" },
|
||||||
|
description: 'Opportunity amount',
|
||||||
|
icon: 'IconCurrencyDollar',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-597c-44d3-98ec-ea71aea5256b': {
|
||||||
|
id: '20202020-597c-44d3-98ec-ea71aea5256b',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.DATE_TIME,
|
||||||
|
name: 'closeDate',
|
||||||
|
label: 'Close date',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Opportunity close date',
|
||||||
|
icon: 'IconCalendarEvent',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-9b94-454a-94ca-8afb09c68faf': {
|
||||||
|
id: '20202020-9b94-454a-94ca-8afb09c68faf',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.SELECT,
|
||||||
|
name: 'stage',
|
||||||
|
label: 'Stage',
|
||||||
|
defaultValue: "'NEW'",
|
||||||
|
description: 'Opportunity stage',
|
||||||
|
icon: 'IconProgressCheck',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: '20202020-dba8-4975-81bd-b29a41c0a387',
|
||||||
|
color: 'red',
|
||||||
|
label: 'New',
|
||||||
|
value: 'NEW',
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20202020-1c9d-490c-940c-bf47addcd6a1',
|
||||||
|
color: 'purple',
|
||||||
|
label: 'Screening',
|
||||||
|
value: 'SCREENING',
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20202020-1368-438b-8702-6d5e5727a888',
|
||||||
|
color: 'sky',
|
||||||
|
label: 'Meeting',
|
||||||
|
value: 'MEETING',
|
||||||
|
position: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20202020-41e4-4b4e-a038-f02645f55767',
|
||||||
|
color: 'turquoise',
|
||||||
|
label: 'Proposal',
|
||||||
|
value: 'PROPOSAL',
|
||||||
|
position: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '20202020-8acf-4934-8519-42f7c9133cd5',
|
||||||
|
color: 'yellow',
|
||||||
|
label: 'Customer',
|
||||||
|
value: 'CUSTOMER',
|
||||||
|
position: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-30a5-4d8e-9b93-12d31ece0aaa': {
|
||||||
|
id: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.POSITION,
|
||||||
|
name: 'position',
|
||||||
|
label: 'Position',
|
||||||
|
defaultValue: 0,
|
||||||
|
description: 'Opportunity record position',
|
||||||
|
icon: 'IconHierarchy2',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-f95f-424f-ab32-65961e8e9635': {
|
||||||
|
id: '20202020-f95f-424f-ab32-65961e8e9635',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.ACTOR,
|
||||||
|
name: 'createdBy',
|
||||||
|
label: 'Created by',
|
||||||
|
defaultValue: { name: "'System'", source: "'MANUAL'", context: {} },
|
||||||
|
description: 'The creator of the record',
|
||||||
|
icon: 'IconCreativeCommonsSa',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-5e10-4780-babb-38a465ac546c': {
|
||||||
|
id: '20202020-5e10-4780-babb-38a465ac546c',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.TEXT,
|
||||||
|
name: 'searchVector',
|
||||||
|
label: 'Search vector',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Field used for full-text search',
|
||||||
|
icon: 'IconUser',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-8f4a-4f8d-822e-90fe72f75b79': {
|
||||||
|
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.UUID,
|
||||||
|
name: 'id',
|
||||||
|
label: 'Id',
|
||||||
|
defaultValue: 'uuid',
|
||||||
|
description: 'Id',
|
||||||
|
icon: 'Icon123',
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-f120-4b59-b239-f7f1d8eb243e': {
|
||||||
|
id: '20202020-f120-4b59-b239-f7f1d8eb243e',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.DATE_TIME,
|
||||||
|
name: 'createdAt',
|
||||||
|
label: 'Creation date',
|
||||||
|
defaultValue: 'now',
|
||||||
|
description: 'Creation date',
|
||||||
|
icon: 'IconCalendar',
|
||||||
|
settings: { displayFormat: 'RELATIVE' } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: false,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-dcc8-4318-9756-b87377692561': {
|
||||||
|
id: '20202020-dcc8-4318-9756-b87377692561',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.DATE_TIME,
|
||||||
|
name: 'updatedAt',
|
||||||
|
label: 'Last update',
|
||||||
|
defaultValue: 'now',
|
||||||
|
description: 'Last time the record was changed',
|
||||||
|
icon: 'IconCalendarClock',
|
||||||
|
settings: { displayFormat: 'RELATIVE' } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: false,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: false,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-1694-4f8b-8760-61a5ff330022': {
|
||||||
|
id: '20202020-1694-4f8b-8760-61a5ff330022',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.DATE_TIME,
|
||||||
|
name: 'deletedAt',
|
||||||
|
label: 'Deleted at',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Date when the record was deleted',
|
||||||
|
icon: 'IconCalendarMinus',
|
||||||
|
settings: { displayFormat: 'RELATIVE' } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-4f52-4dea-a116-723f9bf7f082': {
|
||||||
|
id: '20202020-4f52-4dea-a116-723f9bf7f082',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'pointOfContact',
|
||||||
|
label: 'Point of Contact',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Opportunity point of contact',
|
||||||
|
icon: 'IconUser',
|
||||||
|
settings: {
|
||||||
|
onDelete: 'SET_NULL',
|
||||||
|
relationType: 'MANY_TO_ONE',
|
||||||
|
joinColumnName: 'pointOfContactId',
|
||||||
|
} as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
relationTargetFieldMetadataId: '20202020-a36b-4889-97d4-63a578423688',
|
||||||
|
relationTargetObjectMetadataId: '20202020-6799-4a38-92d3-8e844a7ea8ab',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-fc02-4be2-be1a-e121daf5400d': {
|
||||||
|
id: '20202020-fc02-4be2-be1a-e121daf5400d',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'company',
|
||||||
|
label: 'Company',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Opportunity company',
|
||||||
|
icon: 'IconBuildingSkyscraper',
|
||||||
|
settings: {
|
||||||
|
onDelete: 'SET_NULL',
|
||||||
|
relationType: 'MANY_TO_ONE',
|
||||||
|
joinColumnName: 'companyId',
|
||||||
|
} as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
relationTargetFieldMetadataId: '20202020-bd16-4f63-8165-0a7f5d78170d',
|
||||||
|
relationTargetObjectMetadataId: '20202020-0be8-4764-8e0d-7a2e1c66f78c',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-fd9f-48f0-bd5f-5b0fec6a5de4': {
|
||||||
|
id: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'favorites',
|
||||||
|
label: 'Favorites',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Favorites linked to the opportunity',
|
||||||
|
icon: 'IconHeart',
|
||||||
|
settings: { relationType: RelationType.ONE_TO_MANY } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: true,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
relationTargetFieldMetadataId: '20202020-7c3c-4149-b400-5a958910c7a2',
|
||||||
|
relationTargetObjectMetadataId: '20202020-df10-42dc-baed-ea77db7ad96c',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-88ab-4138-98ce-80533bb423e3': {
|
||||||
|
id: '20202020-88ab-4138-98ce-80533bb423e3',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'taskTargets',
|
||||||
|
label: 'Tasks',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Tasks tied to the opportunity',
|
||||||
|
icon: 'IconCheckbox',
|
||||||
|
settings: { relationType: RelationType.ONE_TO_MANY } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: false,
|
||||||
|
relationTargetFieldMetadataId: '20202020-eb77-4b1c-b6a6-d5dcd13b1634',
|
||||||
|
relationTargetObjectMetadataId: '20202020-3af1-4c4f-90f4-cd43c53f7f41',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-4258-422b-b35b-db3f090af8da': {
|
||||||
|
id: '20202020-4258-422b-b35b-db3f090af8da',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'noteTargets',
|
||||||
|
label: 'Notes',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Notes tied to the opportunity',
|
||||||
|
icon: 'IconNotes',
|
||||||
|
settings: { relationType: RelationType.ONE_TO_MANY } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: false,
|
||||||
|
relationTargetFieldMetadataId: '20202020-d927-4c91-9893-28cea5aff979',
|
||||||
|
relationTargetObjectMetadataId: '20202020-8a5e-4dea-868b-71611e718a73',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-16ca-40a7-a1ba-712975c916cd': {
|
||||||
|
id: '20202020-16ca-40a7-a1ba-712975c916cd',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'attachments',
|
||||||
|
label: 'Attachments',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Attachments linked to the opportunity',
|
||||||
|
icon: 'IconFileImport',
|
||||||
|
settings: { relationType: RelationType.ONE_TO_MANY } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
relationTargetFieldMetadataId: '20202020-9236-427a-8a8a-a2296b93b542',
|
||||||
|
relationTargetObjectMetadataId: '20202020-3005-4c93-a04c-2941f7424f54',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
'20202020-92a5-47bf-a38d-c1c72b2c3e4d': {
|
||||||
|
id: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
|
||||||
|
objectMetadataId: '20202020-6e2c-42f6-a83c-cc58d776af88',
|
||||||
|
type: FieldMetadataType.RELATION,
|
||||||
|
name: 'timelineActivities',
|
||||||
|
label: 'Timeline Activities',
|
||||||
|
defaultValue: null,
|
||||||
|
description: 'Timeline Activities linked to the opportunity.',
|
||||||
|
icon: 'IconTimelineEvent',
|
||||||
|
settings: { relationType: RelationType.ONE_TO_MANY } as any,
|
||||||
|
isCustom: false,
|
||||||
|
isActive: true,
|
||||||
|
isSystem: false,
|
||||||
|
isNullable: true,
|
||||||
|
isUnique: false,
|
||||||
|
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
|
||||||
|
isLabelSyncedWithName: true,
|
||||||
|
relationTargetFieldMetadataId: '20202020-2d54-41c7-b886-29deca3c28d5',
|
||||||
|
relationTargetObjectMetadataId: '20202020-a89e-4e4d-b3d9-c3f99e7c7483',
|
||||||
|
createdAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
updatedAt: new Date('2025-06-27T12:55:13.271Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fieldIdByName: {
|
||||||
|
name: '20202020-c2f1-4435-adca-22931f8b41b6',
|
||||||
|
amount: '20202020-5eef-417a-b517-ebeedaa8e10b',
|
||||||
|
closeDate: '20202020-597c-44d3-98ec-ea71aea5256b',
|
||||||
|
stage: '20202020-9b94-454a-94ca-8afb09c68faf',
|
||||||
|
position: '20202020-30a5-4d8e-9b93-12d31ece0aaa',
|
||||||
|
createdBy: '20202020-f95f-424f-ab32-65961e8e9635',
|
||||||
|
searchVector: '20202020-5e10-4780-babb-38a465ac546c',
|
||||||
|
id: '20202020-8f4a-4f8d-822e-90fe72f75b79',
|
||||||
|
createdAt: '20202020-f120-4b59-b239-f7f1d8eb243e',
|
||||||
|
updatedAt: '20202020-dcc8-4318-9756-b87377692561',
|
||||||
|
deletedAt: '20202020-1694-4f8b-8760-61a5ff330022',
|
||||||
|
pointOfContact: '20202020-4f52-4dea-a116-723f9bf7f082',
|
||||||
|
company: '20202020-fc02-4be2-be1a-e121daf5400d',
|
||||||
|
favorites: '20202020-fd9f-48f0-bd5f-5b0fec6a5de4',
|
||||||
|
taskTargets: '20202020-88ab-4138-98ce-80533bb423e3',
|
||||||
|
noteTargets: '20202020-4258-422b-b35b-db3f090af8da',
|
||||||
|
attachments: '20202020-16ca-40a7-a1ba-712975c916cd',
|
||||||
|
timelineActivities: '20202020-92a5-47bf-a38d-c1c72b2c3e4d',
|
||||||
|
},
|
||||||
|
fieldIdByJoinColumnName: {
|
||||||
|
pointOfContactId: '20202020-4f52-4dea-a116-723f9bf7f082',
|
||||||
|
companyId: '20202020-fc02-4be2-be1a-e121daf5400d',
|
||||||
|
},
|
||||||
|
} as const satisfies ObjectMetadataItemWithFieldMaps;
|
||||||
@ -1,16 +1,22 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { FieldMetadataType } from 'twenty-shared/types';
|
||||||
|
import { isDefined } from 'twenty-shared/utils';
|
||||||
|
|
||||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
||||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
||||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
||||||
|
import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||||
|
|
||||||
export const checkFields = (
|
export const checkFields = (
|
||||||
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
|
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps,
|
||||||
fieldNames: string[],
|
fieldNames: string[],
|
||||||
): void => {
|
): void => {
|
||||||
const fieldMetadataNames = Object.values(objectMetadataItem.fieldsById)
|
const fieldMetadataNames: string[] = Object.values(
|
||||||
.map((field) => {
|
objectMetadataItemWithFieldsMaps.fieldsById,
|
||||||
|
)
|
||||||
|
.flatMap((field) => {
|
||||||
if (isCompositeFieldMetadataType(field.type)) {
|
if (isCompositeFieldMetadataType(field.type)) {
|
||||||
const compositeType = compositeTypeDefinitions.get(field.type);
|
const compositeType = compositeTypeDefinitions.get(field.type);
|
||||||
|
|
||||||
@ -29,15 +35,19 @@ export const checkFields = (
|
|||||||
].flat();
|
].flat();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION)) {
|
||||||
|
return field.settings?.joinColumnName;
|
||||||
|
}
|
||||||
|
|
||||||
return field.name;
|
return field.name;
|
||||||
})
|
})
|
||||||
.flat();
|
.filter(isDefined);
|
||||||
|
|
||||||
for (const fieldName of fieldNames) {
|
for (const fieldName of fieldNames) {
|
||||||
if (!fieldMetadataNames.includes(fieldName)) {
|
if (!fieldMetadataNames.includes(fieldName)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
|
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
|
||||||
objectMetadataItem,
|
objectMetadataItemWithFieldsMaps,
|
||||||
)}' object`,
|
)}' object`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,48 +0,0 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
|
||||||
|
|
||||||
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
|
|
||||||
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
|
|
||||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
|
||||||
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
|
|
||||||
|
|
||||||
export const checkArrayFields = (
|
|
||||||
objectMetadataItem: ObjectMetadataItemWithFieldMaps,
|
|
||||||
fields: Array<Partial<ObjectRecord>>,
|
|
||||||
): void => {
|
|
||||||
const fieldMetadataNames = Object.values(objectMetadataItem.fieldsById)
|
|
||||||
.map((field) => {
|
|
||||||
if (isCompositeFieldMetadataType(field.type)) {
|
|
||||||
const compositeType = compositeTypeDefinitions.get(field.type);
|
|
||||||
|
|
||||||
if (!compositeType) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Composite type '${field.type}' not found`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
field.name,
|
|
||||||
compositeType.properties.map(
|
|
||||||
(compositeProperty) => compositeProperty.name,
|
|
||||||
),
|
|
||||||
].flat();
|
|
||||||
}
|
|
||||||
|
|
||||||
return field.name;
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
for (const fieldObj of fields) {
|
|
||||||
for (const fieldName in fieldObj) {
|
|
||||||
if (!fieldMetadataNames.includes(fieldName)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`field '${fieldName}' does not exist in '${computeObjectTargetTable(
|
|
||||||
objectMetadataItem,
|
|
||||||
)}' object`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -7,7 +7,7 @@ import {
|
|||||||
OrderByDirection,
|
OrderByDirection,
|
||||||
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||||
|
|
||||||
import { checkArrayFields } from 'src/engine/api/rest/core/query-builder/utils/check-order-by.utils';
|
import { checkFields } from 'src/engine/api/rest/core/query-builder/utils/check-fields.utils';
|
||||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||||
|
|
||||||
@ -82,7 +82,10 @@ export class OrderByInputFactory {
|
|||||||
result = [...result, ...resultFields];
|
result = [...result, ...resultFields];
|
||||||
}
|
}
|
||||||
|
|
||||||
checkArrayFields(objectMetadata.objectMetadataMapItem, result);
|
checkFields(
|
||||||
|
objectMetadata.objectMetadataMapItem,
|
||||||
|
result.flatMap((fields) => Object.keys(fields)),
|
||||||
|
);
|
||||||
|
|
||||||
return this.addDefaultOrderById(result);
|
return this.addDefaultOrderById(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -197,6 +197,34 @@ describe('Core REST API Find Many endpoint', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should support filtering on a relation field id', async () => {
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people?filter=companyId[in]:["${TEST_COMPANY_1_ID}"]`,
|
||||||
|
}).expect(200);
|
||||||
|
|
||||||
|
const filteredPeople = response.body.data.people;
|
||||||
|
|
||||||
|
expect(filteredPeople.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail to filter on a relation field name', async () => {
|
||||||
|
const response = await makeRestAPIRequest({
|
||||||
|
method: 'get',
|
||||||
|
path: `/people?filter=company[in]:["${TEST_COMPANY_1_ID}"]`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.body).toMatchInlineSnapshot(`
|
||||||
|
{
|
||||||
|
"error": "BadRequestException",
|
||||||
|
"messages": [
|
||||||
|
"field 'company' does not exist in 'person' object",
|
||||||
|
],
|
||||||
|
"statusCode": 400,
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
it('should support ordering Desc of results', async () => {
|
it('should support ordering Desc of results', async () => {
|
||||||
const descResponse = await makeRestAPIRequest({
|
const descResponse = await makeRestAPIRequest({
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
Reference in New Issue
Block a user