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:
Charles Bochet
2026-04-12 17:03:09 +02:00
committed by GitHub
parent d9d3648baa
commit ad1a4ecca0
6 changed files with 169 additions and 16 deletions
@@ -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;
@@ -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
@@ -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) {
@@ -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;
};