diff --git a/.vscode/launch.json b/.vscode/launch.json index f9f802e42..2d94aa7ca 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -82,6 +82,27 @@ "env": { "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" + }, } ] } \ No newline at end of file diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/__snapshots__/check-fields.utils.spec.ts.snap b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/__snapshots__/check-fields.utils.spec.ts.snap new file mode 100644 index 000000000..f83fe6762 --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/__snapshots__/check-fields.utils.spec.ts.snap @@ -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"`; diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/check-fields.utils.spec.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/check-fields.utils.spec.ts index b825d0d6b..27bd82ea8 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/check-fields.utils.spec.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/check-fields.utils.spec.ts @@ -1,76 +1,63 @@ -import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; +import { EachTestingContext } from 'twenty-shared/testing'; -import { - fieldNumberMock, - objectMetadataItemMock, -} from 'src/engine/api/__mocks__/object-metadata-item.mock'; +import { OPPORTUNITY_WITH_FIELDS_MAPS } from 'src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock'; 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', () => { - const completeFieldNumberMock: FieldMetadataInterface = { - id: 'field-number-id', - type: fieldNumberMock.type, - name: fieldNumberMock.name, - label: 'Field Number', - objectMetadataId: 'object-metadata-id', - isNullable: fieldNumberMock.isNullable, - defaultValue: fieldNumberMock.defaultValue, - isLabelSyncedWithName: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const fieldsById: FieldMetadataMap = { - 'field-number-id': completeFieldNumberMock, - }; - - const mockObjectMetadataWithFieldMaps = { - ...objectMetadataItemMock, - fieldsById, - fieldIdByName: { - [completeFieldNumberMock.name]: completeFieldNumberMock.id, + const testCases: EachTestingContext<{ + fields: string[]; + shouldThrow?: boolean; + }>[] = [ + { + title: 'should accept valid join column id', + context: { + fields: ['pointOfContactId'], + }, }, - 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', () => { - expect(() => - checkFields(mockObjectMetadataWithFieldMaps, ['fieldNumber']), - ).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(); + it.each(testCases)('$title', ({ context }) => { + if (context.shouldThrow) { + expect(() => + checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields), + ).toThrowErrorMatchingSnapshot(); + } else { + expect(() => + checkFields(OPPORTUNITY_WITH_FIELDS_MAPS, context.fields), + ).not.toThrow(); + } }); }); diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock.ts new file mode 100644 index 000000000..9f10e86bf --- /dev/null +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/__tests__/mocks/opportunity-field-maps.mock.ts @@ -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; diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-fields.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-fields.utils.ts index a23e341d6..f4ffedb22 100644 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-fields.utils.ts @@ -1,16 +1,22 @@ 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 { 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'; +import { isFieldMetadataInterfaceOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; export const checkFields = ( - objectMetadataItem: ObjectMetadataItemWithFieldMaps, + objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps, fieldNames: string[], ): void => { - const fieldMetadataNames = Object.values(objectMetadataItem.fieldsById) - .map((field) => { + const fieldMetadataNames: string[] = Object.values( + objectMetadataItemWithFieldsMaps.fieldsById, + ) + .flatMap((field) => { if (isCompositeFieldMetadataType(field.type)) { const compositeType = compositeTypeDefinitions.get(field.type); @@ -29,15 +35,19 @@ export const checkFields = ( ].flat(); } + if (isFieldMetadataInterfaceOfType(field, FieldMetadataType.RELATION)) { + return field.settings?.joinColumnName; + } + return field.name; }) - .flat(); + .filter(isDefined); for (const fieldName of fieldNames) { if (!fieldMetadataNames.includes(fieldName)) { throw new BadRequestException( `field '${fieldName}' does not exist in '${computeObjectTargetTable( - objectMetadataItem, + objectMetadataItemWithFieldsMaps, )}' object`, ); } diff --git a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-order-by.utils.ts b/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-order-by.utils.ts deleted file mode 100644 index 14388513b..000000000 --- a/packages/twenty-server/src/engine/api/rest/core/query-builder/utils/check-order-by.utils.ts +++ /dev/null @@ -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>, -): 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`, - ); - } - } - } -}; diff --git a/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts b/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts index 45b9793e4..9b8543613 100644 --- a/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts +++ b/packages/twenty-server/src/engine/api/rest/input-factories/order-by-input.factory.ts @@ -7,7 +7,7 @@ import { OrderByDirection, } 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 { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; @@ -82,7 +82,10 @@ export class OrderByInputFactory { result = [...result, ...resultFields]; } - checkArrayFields(objectMetadata.objectMetadataMapItem, result); + checkFields( + objectMetadata.objectMetadataMapItem, + result.flatMap((fields) => Object.keys(fields)), + ); return this.addDefaultOrderById(result); } diff --git a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts index 09729f183..6e4564297 100644 --- a/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/rest-api-core-find-many.integration-spec.ts @@ -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 () => { const descResponse = await makeRestAPIRequest({ method: 'get',