diff --git a/packages/twenty-sdk/src/sdk/fields/__tests__/define-field.spec.ts b/packages/twenty-sdk/src/sdk/fields/__tests__/define-field.spec.ts index 963e507f5a..91deb6ca72 100644 --- a/packages/twenty-sdk/src/sdk/fields/__tests__/define-field.spec.ts +++ b/packages/twenty-sdk/src/sdk/fields/__tests__/define-field.spec.ts @@ -175,5 +175,58 @@ describe('defineField', () => { 'Field "Status" is a SELECT/MULTI_SELECT type and must have options', ); }); + + it('should accept isUnique on a TEXT field', () => { + const config: FieldManifest = { + ...validConfig, + isUnique: true, + }; + + const result = defineField(config); + + expect(result.success).toBe(true); + expect(result.config?.isUnique).toBe(true); + }); + + it('should return error when isUnique is set on a RELATION field', () => { + const config = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.RELATION, + name: 'company', + label: 'Company', + isUnique: true, + relationTargetFieldMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440002', + relationTargetObjectMetadataUniversalIdentifier: + '550e8400-e29b-41d4-a716-446655440003', + universalSettings: { relationType: 'ONE_TO_MANY' }, + }; + + const result = defineField(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + `Field "Company" of type ${FieldMetadataType.RELATION} cannot be unique`, + ); + }); + + it('should return error when isUnique is set on a FILES field', () => { + const config = { + objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f', + universalIdentifier: '550e8400-e29b-41d4-a716-446655440001', + type: FieldMetadataType.FILES, + name: 'attachments', + label: 'Attachments', + isUnique: true, + }; + + const result = defineField(config as any); + + expect(result.success).toBe(false); + expect(result.errors).toContain( + `Field "Attachments" of type ${FieldMetadataType.FILES} cannot be unique`, + ); + }); }); }); diff --git a/packages/twenty-sdk/src/sdk/fields/validate-fields.ts b/packages/twenty-sdk/src/sdk/fields/validate-fields.ts index 359708c460..16a50b6ea0 100644 --- a/packages/twenty-sdk/src/sdk/fields/validate-fields.ts +++ b/packages/twenty-sdk/src/sdk/fields/validate-fields.ts @@ -35,6 +35,17 @@ export const validateFields = ( `Field "${field.label}" is a SELECT/MULTI_SELECT type and must have options`, ); } + + if ( + field.isUnique === true && + (field.type === FieldMetadataType.RELATION || + field.type === FieldMetadataType.MORPH_RELATION || + field.type === FieldMetadataType.FILES) + ) { + errors.push( + `Field "${field.label}" of type ${field.type} cannot be unique`, + ); + } } return errors; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts index ceb1e7b69a..8242906457 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts @@ -83,7 +83,7 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({ isSystem: fieldManifest.name in PARTIAL_SYSTEM_FLAT_FIELD_METADATAS, isUIReadOnly: false, isNullable: fieldManifest.isNullable ?? true, - isUnique: false, + isUnique: fieldManifest.isUnique ?? false, isLabelSyncedWithName: false, morphId: fieldManifest.type === FieldMetadataType.MORPH_RELATION diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index d17bc5cca4..765b671973 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -2,6 +2,8 @@ import { type Manifest } from 'twenty-shared/application'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; + import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util'; import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util'; @@ -44,12 +46,14 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ ownerFlatApplication; for (const objectManifest of manifest.objects) { + const flatObjectMetadata = fromObjectManifestToUniversalFlatObjectMetadata({ + objectManifest, + applicationUniversalIdentifier, + now, + }); + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromObjectManifestToUniversalFlatObjectMetadata({ - objectManifest, - applicationUniversalIdentifier, - now, - }), + universalFlatEntity: flatObjectMetadata, universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatObjectMetadataMaps, }); @@ -71,28 +75,61 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ objectUniversalIdentifier: objectManifest.universalIdentifier, }; + const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: enrichedFieldManifest, + applicationUniversalIdentifier, + now, + }); + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromFieldManifestToUniversalFlatFieldMetadata({ - fieldManifest: enrichedFieldManifest, - applicationUniversalIdentifier, - now, - }), + universalFlatEntity: flatFieldMetadata, universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatFieldMetadataMaps, }); + + if (flatFieldMetadata.isUnique) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: generateIndexForFlatFieldMetadata({ + flatFieldMetadata, + flatObjectMetadata, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatIndexMaps, + }); + } } } for (const fieldManifest of manifest.fields) { + const flatFieldMetadata = fromFieldManifestToUniversalFlatFieldMetadata({ + fieldManifest: fieldManifest, + applicationUniversalIdentifier, + now, + }); + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ - universalFlatEntity: fromFieldManifestToUniversalFlatFieldMetadata({ - fieldManifest: fieldManifest, - applicationUniversalIdentifier, - now, - }), + universalFlatEntity: flatFieldMetadata, universalFlatEntityMapsToMutate: allUniversalFlatEntityMaps.flatFieldMetadataMaps, }); + + if (flatFieldMetadata.isUnique) { + const flatObjectMetadata = + allUniversalFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[ + flatFieldMetadata.objectMetadataUniversalIdentifier + ]; + + if (isDefined(flatObjectMetadata)) { + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: generateIndexForFlatFieldMetadata({ + flatFieldMetadata, + flatObjectMetadata, + }), + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatIndexMaps, + }); + } + } } for (const logicFunctionManifest of manifest.logicFunctions) { diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts index c7c4e9e6a1..670eb18980 100644 --- a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-field.integration-spec.ts @@ -268,4 +268,55 @@ describe('Manifest update - fields', () => { ), ).toBeUndefined(); }, 60000); + + it('should create a unique index when field has isUnique set to true', async () => { + await syncApplication({ + manifest: buildManifest({ + fields: [ + { + universalIdentifier: TEST_FIELD_ID, + type: FieldMetadataType.TEXT, + name: 'externalId', + label: 'External ID', + description: 'Unique external identifier', + icon: 'IconId', + isUnique: true, + isNullable: false, + objectUniversalIdentifier: TEST_OBJECT.universalIdentifier, + }, + ], + }), + expectToFail: false, + }); + + const objects = await findManyObjectMetadataWithIndexes({ + expectToFail: false, + }); + + const object = objects.find( + (objectMetadata) => + objectMetadata.universalIdentifier === + TEST_OBJECT.universalIdentifier, + ); + + expect(object).toBeDefined(); + + const externalIdField = object?.fieldsList.find( + (field) => field.name === 'externalId', + ); + + expect(externalIdField).toBeDefined(); + + const uniqueIndex = object?.indexMetadataList.find( + (index) => + index.isUnique && + index.indexFieldMetadataList.some( + (indexField) => + indexField.fieldMetadataId === externalIdField?.id, + ), + ); + + expect(uniqueIndex).toBeDefined(); + expect(uniqueIndex?.isUnique).toBe(true); + }, 60000); }); diff --git a/packages/twenty-shared/src/application/fieldManifestType.ts b/packages/twenty-shared/src/application/fieldManifestType.ts index 1336b3cb2f..370ec23b16 100644 --- a/packages/twenty-shared/src/application/fieldManifestType.ts +++ b/packages/twenty-shared/src/application/fieldManifestType.ts @@ -22,6 +22,7 @@ export type RegularFieldManifest< options?: FieldMetadataOptions; universalSettings?: FieldMetadataUniversalSettings; isNullable?: boolean; + isUnique?: boolean; objectUniversalIdentifier: string; };