Unique field - add unique property creation/deletion on field (#13539)

Done : 
- add isUnique prop availability on gql update/create fieldMetadata
resolvers
- add unique index creation logic at update & creation
- add unique index deletion logic at update
- update unique index if field name updated
- edge cases : can't have default value and unique fields / standard
default value excluded from index (where clause) / can't have composite
unique fields / can't create unique fields on MORPH


closes https://github.com/twentyhq/core-team-issues/issues/1222
This commit is contained in:
Etienne
2025-08-08 17:42:50 +02:00
committed by GitHub
parent 415f57ffa5
commit 7ce96049b8
18 changed files with 1485 additions and 53 deletions
@@ -0,0 +1,45 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { computeUniqueIndexWhereClause } from 'src/engine/metadata-modules/index-metadata/utils/compute-unique-index-where-clause.util';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
describe('computeUniqueIndexWhereClause', () => {
it('should return undefined if standard default value is not defined', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.UUID,
name: 'testField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBeUndefined();
});
it('should return a where clause for a an atomic type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.TEXT,
name: 'testTextField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testTextField" != \'\'');
});
it('should return a where clause for a composite type field', () => {
const fieldMetadata = getMockFieldMetadataEntity({
workspaceId: 'workspace-id',
objectMetadataId: 'object-id',
type: FieldMetadataType.EMAILS,
name: 'testEmailsField',
});
const result = computeUniqueIndexWhereClause(fieldMetadata);
expect(result).toBe('"testEmailsFieldPrimaryEmail" != \'\'');
});
});
@@ -0,0 +1,66 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { validateCanCreateUniqueIndex } from 'src/engine/metadata-modules/index-metadata/utils/validate-can-create-unique-index.util';
describe('validateCanCreateUniqueIndex', () => {
it('should throw an error if field to create is a MORPH type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.MORPH_RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type MORPH_RELATION',
);
});
it('should throw an error if field to create is a RELATION type - ONE_TO_MANY', () => {
const field = {
name: 'testField',
type: FieldMetadataType.RELATION,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type RELATION',
);
});
it('should throw an error if field to create is a FULL_NAME type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.FULL_NAME,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type FULL_NAME',
);
});
it('should throw an error if field to create is an ADDRESS type', () => {
const field = {
name: 'testField',
type: FieldMetadataType.ADDRESS,
isCustom: true,
};
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
FieldMetadataException,
);
expect(() => validateCanCreateUniqueIndex(field)).toThrow(
'Unique index cannot be created for field testField of type ADDRESS',
);
});
});
@@ -0,0 +1,77 @@
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
IndexMetadataException,
IndexMetadataExceptionCode,
} from 'src/engine/metadata-modules/index-metadata/index-field-metadata.exception';
export const computeUniqueIndexWhereClause = (
fieldMetadata: Pick<FieldMetadataEntity, 'type' | 'name'>,
) => {
const defaultDefaultValue = generateDefaultValue(fieldMetadata.type);
if (!isDefined(defaultDefaultValue)) return;
if (
fieldMetadata.type === FieldMetadataType.RELATION ||
fieldMetadata.type === FieldMetadataType.MORPH_RELATION
) {
throw new IndexMetadataException(
`Unique index cannot be created for relation or morph relation field ${fieldMetadata.name}`,
IndexMetadataExceptionCode.INDEX_NOT_SUPPORTED_FOR_MORH_RELATION_FIELD_AND_RELATION_FIELD,
);
}
if (!isCompositeFieldMetadataType(fieldMetadata.type)) {
return `"${fieldMetadata.name}" != ${defaultDefaultValue}`;
}
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!isDefined(compositeType)) {
throw new FieldMetadataException(
`Composite type not found for field metadata type: ${fieldMetadata.type}`,
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
const defaultDefaultValueProperties = Object.keys(defaultDefaultValue);
const columnNamesWithDefaultValues = compositeType.properties
.filter(
(property) =>
property.isIncludedInUniqueConstraint &&
defaultDefaultValueProperties.includes(property.name),
)
.map((property) => {
const defaultValue =
defaultDefaultValue[property.name as keyof typeof defaultDefaultValue];
if (isNonEmptyString(defaultValue)) {
return [
computeCompositeColumnName(fieldMetadata, property),
defaultValue,
];
}
})
.filter(isDefined);
return columnNamesWithDefaultValues.length > 0
? columnNamesWithDefaultValues
.map(
([columnName, defaultValue]) => `"${columnName}" != ${defaultValue}`,
)
.join(' OR ')
: undefined;
};
@@ -0,0 +1,46 @@
import { t } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const validateCanCreateUniqueIndex = (
field: Pick<FieldMetadataEntity, 'type' | 'name' | 'isCustom'>,
) => {
if (field.isCustom === false)
throw new FieldMetadataException(
`Unique index cannot be created on standard field`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`Standard fields cannot be unique.`,
},
);
const isCompositeFieldWithNonIncludedUniqueConstraint =
isCompositeFieldMetadataType(field.type) &&
!compositeTypeDefinitions
.get(field.type)
?.properties.some((property) => property.isIncludedInUniqueConstraint);
if (
[FieldMetadataType.MORPH_RELATION, FieldMetadataType.RELATION].includes(
field.type,
) ||
isCompositeFieldWithNonIncludedUniqueConstraint
) {
const fieldType = field.type;
throw new FieldMetadataException(
`Unique index cannot be created for field ${field.name} of type ${fieldType}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: t`${fieldType} fields cannot be unique.`,
},
);
}
};