Add isUnique support for application-defined fields (#19609)
## Summary - Adds `isUnique?: boolean` to `RegularFieldManifest` in `twenty-shared`, allowing SDK applications to declare unique constraints on fields - Updates the manifest-to-flat-field converter to read `isUnique` from the manifest instead of hardcoding `false` - Generates corresponding unique index metadata in `computeApplicationManifestAllUniversalFlatEntityMaps` when a field has `isUnique: true`, matching the behavior of the `CreateFieldInput` path - Adds SDK-side validation rejecting `isUnique` on RELATION, MORPH_RELATION, and FILES field types - Adds integration test verifying manifest sync creates a unique index for `isUnique` fields - Adds SDK unit tests for `isUnique` validation on unsupported field types ## Test plan - [x] SDK unit tests: `defineField` accepts `isUnique: true` on TEXT, rejects on RELATION and FILES - [ ] Integration test: manifest sync with `isUnique: true` creates the unique index in DB - [ ] Verify `isUnique` defaults to `false` when not specified (backward compatible) - [ ] Verify standalone manifest fields (not nested in objects) also generate unique indexes correctly
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+52
-15
@@ -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) {
|
||||
|
||||
+51
@@ -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);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export type RegularFieldManifest<
|
||||
options?: FieldMetadataOptions<T>;
|
||||
universalSettings?: FieldMetadataUniversalSettings<T>;
|
||||
isNullable?: boolean;
|
||||
isUnique?: boolean;
|
||||
objectUniversalIdentifier: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user