Fix unique standard field (#16371)

Fixes https://github.com/twentyhq/twenty/issues/15925

- update field metadata update logic
- uniformize the way index are named
- command to migrate v1-named unique index
- add integration testing

---------

Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
Etienne
2025-12-08 19:05:28 +01:00
committed by GitHub
parent 950f452fef
commit 07cfaa78ef
23 changed files with 811 additions and 269 deletions
@@ -42,10 +42,12 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
if (rawCreateFieldInput.isRemoteCreation) {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: "Remote fields aren't supported",
},
errors: [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: "Remote fields aren't supported",
},
],
};
}
@@ -60,11 +62,13 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
if (!isDefined(parentFlatObjectMetadata)) {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'Provided object metadata id does not exist',
userFriendlyMessage: msg`Created field metadata, parent object metadata not found`,
},
errors: [
{
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'Provided object metadata id does not exist',
userFriendlyMessage: msg`Created field metadata, parent object metadata not found`,
},
],
};
}
@@ -149,10 +153,12 @@ export const fromCreateFieldInputToFlatFieldMetadatasToCreate = async ({
case FieldMetadataType.TS_VECTOR: {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'TS Vector is not supported for field creation',
},
errors: [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'TS Vector is not supported for field creation',
},
],
};
}
case FieldMetadataType.UUID:
@@ -44,12 +44,14 @@ export const fromMorphRelationCreateFieldInputToFlatFieldMetadatas = async ({
) {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Relation creation payload is required`,
userFriendlyMessage: msg`Relation creation payload is required`,
value: rawMorphCreationPayload,
},
errors: [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Relation creation payload is required`,
userFriendlyMessage: msg`Relation creation payload is required`,
value: rawMorphCreationPayload,
},
],
};
}
@@ -37,12 +37,14 @@ export const fromRelationCreateFieldInputToFlatFieldMetadatas = async ({
if (!isDefined(rawCreationPayload)) {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Relation creation payload is required`,
userFriendlyMessage: msg`Relation creation payload is required`,
value: rawCreationPayload,
},
errors: [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: `Relation creation payload is required`,
userFriendlyMessage: msg`Relation creation payload is required`,
value: rawCreationPayload,
},
],
};
}
@@ -6,13 +6,11 @@ import {
} from 'twenty-shared/utils';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { computeFlatFieldToUpdateAndRelatedFlatFieldToUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-to-update-and-related-flat-field-to-update.util';
import { computeFlatFieldToUpdateFromMorphRelationUpdatePayload } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-to-update-from-morph-relation-update-payload.util';
@@ -66,11 +64,13 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
if (!isDefined(existingFlatFieldMetadataToUpdate)) {
return {
status: 'fail',
error: {
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: 'Field metadata to update not found',
userFriendlyMessage: msg`Field metadata to update not found`,
},
errors: [
{
code: FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
message: 'Field metadata to update not found',
userFriendlyMessage: msg`Field metadata to update not found`,
},
],
};
}
@@ -80,10 +80,16 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
});
if (!isDefined(flatObjectMetadata)) {
throw new FieldMetadataException(
'Field to update object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
return {
status: 'fail',
errors: [
{
code: FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
message: 'Field to update object metadata not found',
userFriendlyMessage: msg`Field to update object metadata not found`,
},
],
};
}
const { flatFieldMetadataFromTo, relatedFlatFieldMetadatasFromTo } =
@@ -112,99 +118,121 @@ export const fromUpdateFieldInputToFlatFieldMetadata = ({
flatIndexMetadatasToCreate: [],
};
const initialAccumulator: FlatFieldMetadataAndIndexToUpdate = {
const initialAccumulator: FlatFieldMetadataAndIndexToUpdate & {
errors: FlatFieldMetadataValidationError[];
} = {
...structuredClone(FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS),
flatFieldMetadatasToUpdate: [],
flatFieldMetadatasToCreate: flatFieldMetadatasToCreate,
flatIndexMetadatasToCreate: flatIndexMetadatasToCreate,
errors: [],
};
const optimisticiallyUpdatedFlatFieldMetadatas = [
const { errors: sideEffectErrors, ...sideEffectFlatEntityOperations } = [
flatFieldMetadataFromTo,
...relatedFlatFieldMetadatasFromTo,
].reduce<FlatFieldMetadataAndIndexToUpdate>(
(accumulator, { fromFlatFieldMetadata, toFlatFieldMetadata }) => {
const {
flatViewGroupsToCreate,
flatViewGroupsToDelete,
flatViewGroupsToUpdate,
flatIndexMetadatasToUpdate,
flatViewFiltersToDelete,
flatViewFiltersToUpdate,
flatIndexMetadatasToCreate,
flatIndexMetadatasToDelete,
flatViewsToDelete,
flatViewFieldsToDelete,
flatViewsToUpdate,
} = handleFlatFieldMetadataUpdateSideEffect({
flatViewFilterMaps,
flatViewGroupMaps,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
fromFlatFieldMetadata,
flatFieldMetadataMaps,
flatIndexMaps,
toFlatFieldMetadata,
flatViewMaps,
flatViewFieldMaps,
});
].reduce<
FlatFieldMetadataAndIndexToUpdate & {
errors: FlatFieldMetadataValidationError[];
}
>((accumulator, { fromFlatFieldMetadata, toFlatFieldMetadata }) => {
const sideEffectResult = handleFlatFieldMetadataUpdateSideEffect({
flatViewFilterMaps,
flatViewGroupMaps,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
fromFlatFieldMetadata,
flatFieldMetadataMaps,
flatIndexMaps,
toFlatFieldMetadata,
flatViewMaps,
flatViewFieldMaps,
workspaceCustomApplicationId,
});
if (sideEffectResult.status === 'fail') {
return {
flatFieldMetadatasToUpdate: [
...accumulator.flatFieldMetadatasToUpdate,
toFlatFieldMetadata,
],
flatIndexMetadatasToUpdate: [
...accumulator.flatIndexMetadatasToUpdate,
...flatIndexMetadatasToUpdate,
],
flatFieldMetadatasToCreate: [...accumulator.flatFieldMetadatasToCreate],
flatViewFiltersToDelete: [
...accumulator.flatViewFiltersToDelete,
...flatViewFiltersToDelete,
],
flatViewFiltersToUpdate: [
...accumulator.flatViewFiltersToUpdate,
...flatViewFiltersToUpdate,
],
flatViewGroupsToCreate: [
...accumulator.flatViewGroupsToCreate,
...flatViewGroupsToCreate,
],
flatViewGroupsToDelete: [
...accumulator.flatViewGroupsToDelete,
...flatViewGroupsToDelete,
],
flatViewGroupsToUpdate: [
...accumulator.flatViewGroupsToUpdate,
...flatViewGroupsToUpdate,
],
flatIndexMetadatasToDelete: [
...accumulator.flatIndexMetadatasToDelete,
...flatIndexMetadatasToDelete,
],
flatIndexMetadatasToCreate: [
...accumulator.flatIndexMetadatasToCreate,
...flatIndexMetadatasToCreate,
],
flatViewsToDelete: [
...accumulator.flatViewsToDelete,
...flatViewsToDelete,
],
flatViewFieldsToDelete: [
...accumulator.flatViewFieldsToDelete,
...flatViewFieldsToDelete,
],
flatViewsToUpdate: [
...accumulator.flatViewsToUpdate,
...flatViewsToUpdate,
],
...accumulator,
errors: [...accumulator.errors, ...sideEffectResult.errors],
};
},
initialAccumulator,
);
}
const {
flatViewGroupsToCreate,
flatViewGroupsToDelete,
flatViewGroupsToUpdate,
flatIndexMetadatasToUpdate,
flatViewFiltersToDelete,
flatViewFiltersToUpdate,
flatIndexMetadatasToCreate,
flatIndexMetadatasToDelete,
flatViewsToDelete,
flatViewFieldsToDelete,
flatViewsToUpdate,
} = sideEffectResult.result;
return {
flatFieldMetadatasToUpdate: [
...accumulator.flatFieldMetadatasToUpdate,
toFlatFieldMetadata,
],
flatIndexMetadatasToUpdate: [
...accumulator.flatIndexMetadatasToUpdate,
...flatIndexMetadatasToUpdate,
],
flatFieldMetadatasToCreate: [...accumulator.flatFieldMetadatasToCreate],
flatViewFiltersToDelete: [
...accumulator.flatViewFiltersToDelete,
...flatViewFiltersToDelete,
],
flatViewFiltersToUpdate: [
...accumulator.flatViewFiltersToUpdate,
...flatViewFiltersToUpdate,
],
flatViewGroupsToCreate: [
...accumulator.flatViewGroupsToCreate,
...flatViewGroupsToCreate,
],
flatViewGroupsToDelete: [
...accumulator.flatViewGroupsToDelete,
...flatViewGroupsToDelete,
],
flatViewGroupsToUpdate: [
...accumulator.flatViewGroupsToUpdate,
...flatViewGroupsToUpdate,
],
flatIndexMetadatasToDelete: [
...accumulator.flatIndexMetadatasToDelete,
...flatIndexMetadatasToDelete,
],
flatIndexMetadatasToCreate: [
...accumulator.flatIndexMetadatasToCreate,
...flatIndexMetadatasToCreate,
],
flatViewsToDelete: [
...accumulator.flatViewsToDelete,
...flatViewsToDelete,
],
flatViewFieldsToDelete: [
...accumulator.flatViewFieldsToDelete,
...flatViewFieldsToDelete,
],
flatViewsToUpdate: [
...accumulator.flatViewsToUpdate,
...flatViewsToUpdate,
],
errors: accumulator.errors,
};
}, initialAccumulator);
if (sideEffectErrors.length > 0) {
return {
status: 'fail',
errors: sideEffectErrors,
};
}
return {
status: 'success',
result: optimisticiallyUpdatedFlatFieldMetadatas,
result: sideEffectFlatEntityOperations,
};
};
@@ -1,6 +1,7 @@
import { type FromTo } from 'twenty-shared/types';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { handleEnumFlatFieldMetadataUpdateSideEffects } from 'src/engine/metadata-modules/flat-field-metadata/utils/handle-enum-flat-field-metadata-update-side-effects.util';
import {
@@ -34,7 +35,9 @@ type HandleFlatFieldMetadataUpdateSideEffectArgs = FromTo<
| 'flatViewGroupMaps'
| 'flatViewMaps'
| 'flatViewFieldMaps'
>;
> & {
workspaceCustomApplicationId: string;
};
export const FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS: FlatFieldMetadataUpdateSideEffects =
{
@@ -61,7 +64,8 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({
flatViewGroupMaps,
flatViewMaps,
flatViewFieldMaps,
}: HandleFlatFieldMetadataUpdateSideEffectArgs): FlatFieldMetadataUpdateSideEffects => {
workspaceCustomApplicationId,
}: HandleFlatFieldMetadataUpdateSideEffectArgs): FieldInputTranspilationResult<FlatFieldMetadataUpdateSideEffects> => {
const sideEffectResult = structuredClone(
FLAT_FIELD_METADATA_UPDATE_EMPTY_SIDE_EFFECTS,
);
@@ -114,18 +118,25 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({
sideEffectResult.flatViewFiltersToDelete.push(...flatViewFiltersToDelete);
}
const {
flatIndexMetadatasToUpdate,
flatIndexMetadatasToCreate,
flatIndexMetadatasToDelete,
} = handleIndexChangesDuringFieldUpdate({
const indexChangesSideEffectResult = handleIndexChangesDuringFieldUpdate({
fromFlatFieldMetadata,
toFlatFieldMetadata,
flatIndexMaps,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
workspaceCustomApplicationId,
});
if (indexChangesSideEffectResult.status === 'fail') {
return indexChangesSideEffectResult;
}
const {
flatIndexMetadatasToUpdate,
flatIndexMetadatasToCreate,
flatIndexMetadatasToDelete,
} = indexChangesSideEffectResult.result;
sideEffectResult.flatIndexMetadatasToUpdate.push(
...flatIndexMetadatasToUpdate,
);
@@ -136,5 +147,8 @@ export const handleFlatFieldMetadataUpdateSideEffect = ({
...flatIndexMetadatasToDelete,
);
return sideEffectResult;
return {
status: 'success',
result: sideEffectResult,
};
};
@@ -1,7 +1,11 @@
import { msg } from '@lingui/core/macro';
import { type FromTo } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FieldInputTranspilationResult } from 'src/engine/metadata-modules/flat-field-metadata/types/field-input-transpilation-result.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { findFieldRelatedIndexes } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-field-related-index.util';
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
@@ -23,7 +27,9 @@ type FromToFlatFieldMetadataAndFlatEntityMaps = FromTo<
Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' | 'flatIndexMaps'
>;
> & {
workspaceCustomApplicationId: string;
};
const FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT: FieldMetadataUpdateIndexSideEffect =
{
flatIndexMetadatasToUpdate: [],
@@ -37,14 +43,18 @@ export const handleIndexChangesDuringFieldUpdate = ({
flatIndexMaps,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: FromToFlatFieldMetadataAndFlatEntityMaps): FieldMetadataUpdateIndexSideEffect => {
workspaceCustomApplicationId,
}: FromToFlatFieldMetadataAndFlatEntityMaps): FieldInputTranspilationResult<FieldMetadataUpdateIndexSideEffect> => {
if (
!hasIndexRelevantChanges({
fromFlatFieldMetadata,
toFlatFieldMetadata,
})
) {
return FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT;
return {
status: 'success',
result: FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
};
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMapsOrThrow({
@@ -71,6 +81,7 @@ export const handleIndexChangesDuringFieldUpdate = ({
relatedIndexes,
flatObjectMetadata,
flatFieldMetadataMaps,
workspaceCustomApplicationId,
});
};
@@ -87,9 +98,12 @@ const handleNoExistingIndexes = ({
}: {
toFlatFieldMetadata: FlatFieldMetadata;
flatObjectMetadata: FlatObjectMetadata;
}): FieldMetadataUpdateIndexSideEffect => {
}): FieldInputTranspilationResult<FieldMetadataUpdateIndexSideEffect> => {
if (!toFlatFieldMetadata.isUnique) {
return FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT;
return {
status: 'success',
result: FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
};
}
const newIndex = generateIndexForFlatFieldMetadata({
@@ -99,8 +113,11 @@ const handleNoExistingIndexes = ({
});
return {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToCreate: [newIndex],
status: 'success',
result: {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToCreate: [newIndex],
},
};
};
@@ -110,14 +127,14 @@ const handleExistingIndexes = ({
relatedIndexes,
flatObjectMetadata,
flatFieldMetadataMaps,
workspaceCustomApplicationId,
}: {
relatedIndexes: FlatIndexMetadata[];
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps'];
} & FromTo<
FlatFieldMetadata,
'flatFieldMetadata'
>): FieldMetadataUpdateIndexSideEffect => {
} & FromTo<FlatFieldMetadata, 'flatFieldMetadata'> & {
workspaceCustomApplicationId: string;
}): FieldInputTranspilationResult<FieldMetadataUpdateIndexSideEffect> => {
if (
toFlatFieldMetadata.isUnique === false &&
!isMorphOrRelationFlatFieldMetadata(fromFlatFieldMetadata)
@@ -135,11 +152,33 @@ const handleExistingIndexes = ({
(index) => index.name === expectedUniqueIndex.name,
);
if (
isDefined(uniqueIndexToDelete) &&
((isDefined(uniqueIndexToDelete.applicationId) &&
uniqueIndexToDelete.applicationId !== workspaceCustomApplicationId) ||
!uniqueIndexToDelete.isCustom)
) {
return {
status: 'fail',
errors: [
{
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'Cannot delete unique index that have not been created by the workspace custom application',
userFriendlyMessage: msg`Cannot delete unique index that have not been created by the workspace custom application`,
},
],
};
}
return {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToDelete: uniqueIndexToDelete
? [uniqueIndexToDelete]
: [],
status: 'success',
result: {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToDelete: uniqueIndexToDelete
? [uniqueIndexToDelete]
: [],
},
};
}
const updatedIndexes = recomputeIndexOnFlatFieldMetadataNameUpdate({
@@ -154,7 +193,10 @@ const handleExistingIndexes = ({
});
return {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToUpdate: updatedIndexes,
status: 'success',
result: {
...FIELD_METADATA_UPDATE_INDEX_SIDE_EFFECT,
flatIndexMetadatasToUpdate: updatedIndexes,
},
};
};
@@ -17,7 +17,7 @@ export const throwOnFieldInputTranspilationsError: ThrowOnFieldInputTranspilatio
) => {
const failedInputTranspilationErrors = inputTranspilationResults.flatMap(
(transpilationResult) =>
transpilationResult.status === 'fail' ? transpilationResult.error : [],
transpilationResult.status === 'fail' ? transpilationResult.errors : [],
);
if (failedInputTranspilationErrors.length > 0) {