FieldMetadataServiceV2.updateOne (#13784)

# Introduction
Implementing the `updateOne` service handler following workspace
migration v2 style
This commit is contained in:
Paul Rastoin
2025-08-12 10:05:53 +00:00
committed by GitHub
parent 37a0f2726e
commit d4b5ed93a3
14 changed files with 565 additions and 93 deletions
@@ -4,8 +4,20 @@ import { IsJSON, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { type APP_LOCALES } from 'twenty-shared/translations';
import { FlatFieldMetadataPropertiesToCompare } from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
export const fieldMetadataStandardOverridesProperties = [
'label',
'description',
'icon',
] as const satisfies FlatFieldMetadataPropertiesToCompare[];
export type FieldMetadataStandardOverridesProperties =
(typeof fieldMetadataStandardOverridesProperties)[number];
@ObjectType('StandardOverrides')
export class FieldStandardOverridesDTO {
export class FieldStandardOverridesDTO
implements Partial<Record<FieldMetadataStandardOverridesProperties, unknown>>
{
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
@@ -87,6 +87,23 @@ export class FieldMetadataResolver {
@AuthWorkspace() { id: workspaceId }: Workspace,
@Context() context: I18nContext,
) {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
try {
return await this.fieldMetadataServiceV2.updateOne({
updateFieldInput: { ...input.update, id: input.id },
workspaceId,
});
} catch (error) {
fieldMetadataGraphqlApiExceptionHandler(error);
}
}
try {
const updatedInput = (await this.beforeUpdateOneField.run(input, {
workspaceId,
@@ -26,7 +26,7 @@ import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-module
import { type ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getObjectMetadataFromObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/utils/get-object-metadata-from-object-metadata-Item-with-field-maps';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
@@ -33,7 +33,7 @@ import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metad
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
type ValidateFieldMetadataArgs = {
fieldMetadataType: FieldMetadataType;
@@ -11,6 +11,7 @@ import { In, Repository } from 'typeorm';
import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
FieldMetadataException,
@@ -21,6 +22,7 @@ import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/met
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadatas-to-create.util';
import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util';
import { fromUpdateFieldInputToFlatFieldMetadataToUpdate } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata-to-update.util';
import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { throwOnFieldInputTranspilationsError } from 'src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
@@ -29,6 +31,7 @@ import { addFlatFieldMetadataInFlatObjectMetadataMaps } from 'src/engine/metadat
import { deleteFieldFromFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/delete-field-from-flat-object-metadata-maps-or-throw.util';
import { getSubFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/get-sub-flat-object-metadata-maps-or-throw.util';
import { getSubFlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/get-sub-flat-object-metadata-maps.util';
import { replaceFlatFieldMetadataInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/replace-flat-field-metadata-in-flat-object-metadata-maps-or-throw.util';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service';
import { WorkspaceMigrationRunnerV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.service';
@@ -105,33 +108,40 @@ export class FieldMetadataServiceV2 {
);
}
const flatObjectMetadataMapsWithImpactedObject =
getSubFlatObjectMetadataMapsOrThrow({
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
objectMetadataIds: flatFieldMetadatasToDelete.map(
(flatFieldMetadataToDelete) =>
flatFieldMetadataToDelete.objectMetadataId,
),
try {
const flatObjectMetadataMapsWithImpactedObject =
getSubFlatObjectMetadataMapsOrThrow({
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
objectMetadataIds: flatFieldMetadatasToDelete.map(
(flatFieldMetadataToDelete) =>
flatFieldMetadataToDelete.objectMetadataId,
),
});
const toFlatObjectMetadataMaps = flatFieldMetadatasToDelete.reduce(
(flatObjectMetadataMaps, flatFieldMetadataToDelete) =>
deleteFieldFromFlatObjectMetadataMapsOrThrow({
fieldMetadataId: flatFieldMetadataToDelete.id,
flatObjectMetadataMaps,
objectMetadataId: flatFieldMetadataToDelete.objectMetadataId,
}),
flatObjectMetadataMapsWithImpactedObject,
);
const workspaceMigration = this.workspaceMigrationBuilderV2.build({
fromFlatObjectMetadataMaps: flatObjectMetadataMapsWithImpactedObject,
toFlatObjectMetadataMaps,
inferDeletionFromMissingObjectFieldIndex: true,
workspaceId,
});
const toFlatObjectMetadataMaps = flatFieldMetadatasToDelete.reduce(
(flatObjectMetadataMaps, flatFieldMetadataToDelete) =>
deleteFieldFromFlatObjectMetadataMapsOrThrow({
fieldMetadataId: flatFieldMetadataToDelete.id,
flatObjectMetadataMaps,
objectMetadataId: flatFieldMetadataToDelete.objectMetadataId,
}),
flatObjectMetadataMapsWithImpactedObject,
);
const workspaceMigration = this.workspaceMigrationBuilderV2.build({
fromFlatObjectMetadataMaps: flatObjectMetadataMapsWithImpactedObject,
toFlatObjectMetadataMaps,
inferDeletionFromMissingObjectFieldIndex: true,
workspaceId,
});
await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
} catch {
throw new FieldMetadataException(
'Workspace migration failed to run',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
// TODO to be discussed with coco regarding storing dates in flat in order to build dtos
// return flatFieldMetadatasToDelete[0];
@@ -188,6 +198,82 @@ export class FieldMetadataServiceV2 {
});
}
async updateOne({
updateFieldInput,
workspaceId,
}: {
updateFieldInput: UpdateFieldInput;
workspaceId: string;
}): Promise<FieldMetadataEntity> {
const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeFlatObjectMetadataMaps(
{ workspaceId },
);
const inputTranspilationResult =
fromUpdateFieldInputToFlatFieldMetadataToUpdate({
existingFlatObjectMetadataMaps,
updateFieldInput,
});
if (inputTranspilationResult.status === 'fail') {
throw inputTranspilationResult.error;
}
const optimisticiallyUpdatedFlatFieldMetadata =
inputTranspilationResult.result;
const validationErrors =
await this.flatFieldMetadataValidatorService.validateFlatFieldMetadataUpdate(
{
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate: optimisticiallyUpdatedFlatFieldMetadata,
workspaceId,
},
);
if (validationErrors.length > 0) {
throw new MultipleMetadataValidationErrors(
validationErrors,
'Multiple validation errors occurred while updating field',
);
}
try {
const fromFlatObjectMetadataMaps = getSubFlatObjectMetadataMapsOrThrow({
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
objectMetadataIds: [
optimisticiallyUpdatedFlatFieldMetadata.objectMetadataId,
],
});
const toFlatObjectMetadataMaps =
replaceFlatFieldMetadataInFlatObjectMetadataMapsOrThrow({
flatObjectMetadataMaps: fromFlatObjectMetadataMaps,
flatFieldMetadata: optimisticiallyUpdatedFlatFieldMetadata,
});
const workspaceMigration = this.workspaceMigrationBuilderV2.build({
fromFlatObjectMetadataMaps,
toFlatObjectMetadataMaps,
inferDeletionFromMissingObjectFieldIndex: false,
workspaceId,
});
await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
return this.fieldMetadataRepository.findOneOrFail({
where: {
id: optimisticiallyUpdatedFlatFieldMetadata.id,
workspaceId,
},
});
} catch {
throw new FieldMetadataException(
'Workspace migration failed to run',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
}
async createMany({
fieldMetadataInputs,
workspaceId,
@@ -240,7 +326,7 @@ export class FieldMetadataServiceV2 {
});
const validationErrors =
await this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadata(
await this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadataCreation(
{
existingFlatObjectMetadataMaps: optimisticFlatObjectMetadataMaps,
flatFieldMetadataToValidate: flatFieldMetadataToCreate,
@@ -1,11 +1,16 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { isEnumValidateOneFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util';
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator';
@@ -14,7 +19,7 @@ import { validateRelationFlatFieldMetadata } from 'src/engine/metadata-modules/f
export class FlatFieldMetadataTypeValidatorService {
constructor(private readonly featureFlagService: FeatureFlagService) {}
public readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator =
private readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator =
{
ACTOR: async (_args) => {
return [];
@@ -126,4 +131,36 @@ export class FlatFieldMetadataTypeValidatorService {
return [];
},
};
public async validateFlatFieldMetadataTypeSpecificities<
T extends FieldMetadataType = FieldMetadataType,
>({
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate,
workspaceId,
otherFlatObjectMetadataMapsToValidate,
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> {
const fieldMetadataTypeValidator =
this.FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[
flatFieldMetadataToValidate.type
];
if (!isDefined(fieldMetadataTypeValidator)) {
return [
new FieldMetadataException(
'Unsupported field metadata type',
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
),
];
}
return await fieldMetadataTypeValidator({
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate,
workspaceId,
otherFlatObjectMetadataMapsToValidate,
});
}
}
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { type FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -11,8 +11,14 @@ import {
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import {
compareTwoFlatFieldMetadata,
type FlatFieldMetadataPropertiesToCompare,
} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-with-flat-field-maps-to-flat-object-metadatas.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@@ -21,7 +27,7 @@ import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateMetadataName } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
export type ValidateOneFieldMetadataArgs<
@@ -39,6 +45,143 @@ export class FlatFieldMetadataValidatorService {
private readonly flatFieldMetadataTypeValidatorService: FlatFieldMetadataTypeValidatorService,
) {}
async validateFlatFieldMetadataUpdate<T extends FieldMetadataType>({
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate: updatedFlatFieldMetadata,
workspaceId,
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> {
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
const flatObjectMetadataWithFlatFieldMaps =
existingFlatObjectMetadataMaps.byId[
updatedFlatFieldMetadata.objectMetadataId
];
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
return [
new FieldMetadataException(
'field metadata to update object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
];
}
if (
!isDefined(
flatObjectMetadataWithFlatFieldMaps.labelIdentifierFieldMetadataId,
)
) {
errors.push(
new FieldMetadataException(
'Label identifier field metadata id does not exist',
FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
),
);
}
const existingFlatFieldMetadataToUpdate =
flatObjectMetadataWithFlatFieldMaps.fieldsById[
updatedFlatFieldMetadata.id
];
if (!isDefined(existingFlatFieldMetadataToUpdate)) {
errors.push(
new FieldMetadataException(
'field metadata to update not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
);
return errors;
}
const updates = compareTwoFlatFieldMetadata({
from: existingFlatFieldMetadataToUpdate,
to: updatedFlatFieldMetadata,
});
if (
isFlatFieldMetadataEntityOfType(
updatedFlatFieldMetadata,
FieldMetadataType.RELATION,
) ||
isFlatFieldMetadataEntityOfType(
updatedFlatFieldMetadata,
FieldMetadataType.MORPH_RELATION,
)
) {
const relationEditableFields = [
'label',
'description',
'isActive',
] as const satisfies FlatFieldMetadataPropertiesToCompare[];
const relationNonEditableUpdatedProperties = updates.flatMap(
({ property }) =>
!relationEditableFields.includes(
property as (typeof relationEditableFields)[number],
)
? property
: [],
);
if (relationNonEditableUpdatedProperties.length > 0) {
errors.push(
new FieldMetadataException(
`Forbidden updated properties for relation field metadata: ${relationNonEditableUpdatedProperties.join(', ')}`,
FieldMetadataExceptionCode.FIELD_MUTATION_NOT_ALLOWED,
),
);
}
}
const flatObjectMetadata =
fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata(
flatObjectMetadataWithFlatFieldMaps,
);
if (updates.some((update) => update.property === 'name')) {
errors.push(
...[
validateMetadataName(updatedFlatFieldMetadata.name),
validateFlatFieldMetadataNameAvailability({
name: updatedFlatFieldMetadata.name,
flatObjectMetadata: flatObjectMetadata,
}),
].filter(isDefined),
);
}
if (updatedFlatFieldMetadata.isLabelSyncedWithName) {
const computedName = computeMetadataNameFromLabel(
updatedFlatFieldMetadata.label,
);
if (updatedFlatFieldMetadata.name !== computedName) {
errors.push(
new InvalidMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${updatedFlatFieldMetadata.name}`,
InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
),
);
}
}
const fieldMetadataTypeValidationErrors =
await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
{
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate: updatedFlatFieldMetadata,
workspaceId,
},
);
if (fieldMetadataTypeValidationErrors.length > 0) {
errors.push(...fieldMetadataTypeValidationErrors);
}
return errors;
}
validateFlatFieldMetadataDeletion({
existingFlatObjectMetadataMaps,
flatFieldMetadataToDelete,
@@ -98,7 +241,7 @@ export class FlatFieldMetadataValidatorService {
return errors;
}
async validateOneFlatFieldMetadata<
async validateOneFlatFieldMetadataCreation<
T extends FieldMetadataType = FieldMetadataType,
>({
existingFlatObjectMetadataMaps,
@@ -140,7 +283,7 @@ export class FlatFieldMetadataValidatorService {
const failedNameAvailabilityValidation =
validateFlatFieldMetadataNameAvailability({
name: flatFieldMetadataToValidate.name,
objectMetadata: parentFlatObjectMetadata,
flatObjectMetadata: parentFlatObjectMetadata,
});
if (isDefined(failedNameAvailabilityValidation)) {
@@ -163,43 +306,26 @@ export class FlatFieldMetadataValidatorService {
}
}
try {
validateMetadataNameOrThrow(flatFieldMetadataToValidate.name);
} catch (error) {
errors.push(
new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
),
);
const nameValidationResult = validateMetadataName(
flatFieldMetadataToValidate.name,
);
if (isDefined(nameValidationResult)) {
errors.push(nameValidationResult);
}
const fieldMetadataTypeValidator =
this.flatFieldMetadataTypeValidatorService
.FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[
flatFieldMetadataToValidate.type
];
if (!isDefined(fieldMetadataTypeValidator)) {
errors.push(
new FieldMetadataException(
'Unsupported field metadata type',
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
),
);
} else {
const fieldMetadataTypeValidatorExceptions =
await fieldMetadataTypeValidator({
const fieldMetadataTypeValidationErrors =
await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
{
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate,
workspaceId,
otherFlatObjectMetadataMapsToValidate,
});
},
);
errors.push(...fieldMetadataTypeValidatorExceptions);
if (fieldMetadataTypeValidationErrors.length > 0) {
errors.push(...fieldMetadataTypeValidationErrors);
}
return errors;
@@ -3,11 +3,10 @@ import { FieldMetadataType, type FromTo } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { isRelationFieldMetadataType } from 'src/engine/utils/is-relation-field-metadata-type.util';
import { type UpdateFieldAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/workspace-migration-field-action-v2';
import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util';
const flatFieldMetadataPropertiesToCompare = [
export const flatFieldMetadataPropertiesToCompare = [
'defaultValue',
'description',
'icon',
@@ -24,11 +23,14 @@ const flatFieldMetadataPropertiesToCompare = [
export type FlatFieldMetadataPropertiesToCompare =
(typeof flatFieldMetadataPropertiesToCompare)[number];
const fieldMetadataPropertiesToStringify = [
export const flatFieldMetadataEntityJsonbProperties = [
'defaultValue',
'standardOverrides',
'options',
'settings',
] as const satisfies FlatFieldMetadataPropertiesToCompare[];
'standardOverrides',
] as const satisfies (keyof FlatFieldMetadata)[];
export type FlatFieldMetadataEntityJsonbProperties =
(typeof flatFieldMetadataEntityJsonbProperties)[number];
const shouldNotOverrideDefaultValue = (type: FieldMetadataType) => {
return [
@@ -73,7 +75,8 @@ export const compareTwoFlatFieldMetadata = ({
// Remove below assertion when we authorize relation edition, see https://github.com/twentyhq/twenty/commit/39f6f3c4bb101272a9014e142a842d0801a3c33b
if (
isDefined(fieldMetadata.type) &&
isRelationFieldMetadataType(fieldMetadata.type) &&
(fieldMetadata.type === FieldMetadataType.RELATION ||
fieldMetadata.type === FieldMetadataType.MORPH_RELATION) &&
!['label', 'description', 'isActive'].includes(property)
) {
return true;
@@ -81,7 +84,7 @@ export const compareTwoFlatFieldMetadata = ({
return false;
},
propertiesToStringify: fieldMetadataPropertiesToStringify,
propertiesToStringify: flatFieldMetadataEntityJsonbProperties,
};
const fromCompare = transformMetadataForComparison(
from,
@@ -100,11 +103,23 @@ export const compareTwoFlatFieldMetadata = ({
switch (difference.type) {
case 'CHANGE': {
const { oldValue, path, value } = difference;
const property = path[0] as FlatFieldMetadataPropertiesToCompare;
const isJsonb = flatFieldMetadataEntityJsonbProperties.includes(
property as FlatFieldMetadataEntityJsonbProperties,
);
if (isJsonb) {
return {
from: isDefined(oldValue) ? JSON.parse(oldValue) : oldValue,
to: isDefined(value) ? JSON.parse(value) : value,
property,
};
}
return {
from: oldValue,
to: value,
property: path[0] as FlatFieldMetadataPropertiesToCompare,
property,
};
}
case 'CREATE':
@@ -0,0 +1,158 @@
import {
extractAndSanitizeObjectStringFields,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import {
fieldMetadataStandardOverridesProperties,
type FieldMetadataStandardOverridesProperties,
} from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
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 { 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 {
flatFieldMetadataPropertiesToCompare,
type FlatFieldMetadataPropertiesToCompare,
} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { findFlatFieldMetadataInFlatObjectMetadataMapsWithOnlyFieldId } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/find-flat-field-metadata-in-flat-object-metadata-maps-with-field-id-only.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
const fieldMetadataEditableProperties =
flatFieldMetadataPropertiesToCompare.filter(
(
property,
): property is Exclude<
FlatFieldMetadataPropertiesToCompare,
'standardOverrides'
> => property !== 'standardOverrides',
);
type FromUpdateFieldInputToFlatFieldMetadataToUpdateArgs = {
existingFlatObjectMetadataMaps: FlatObjectMetadataMaps;
updateFieldInput: UpdateFieldInput;
};
export const fromUpdateFieldInputToFlatFieldMetadataToUpdate = ({
existingFlatObjectMetadataMaps,
updateFieldInput: rawUpdateFieldInput,
}: FromUpdateFieldInputToFlatFieldMetadataToUpdateArgs): FieldInputTranspilationResult<FlatFieldMetadata> => {
const updateFieldInputInformalProperties =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateFieldInput,
['objectMetadataId', 'id'],
);
const updatedEditableFields = extractAndSanitizeObjectStringFields(
rawUpdateFieldInput,
fieldMetadataEditableProperties,
);
const relatedFlatFieldMetadata =
findFlatFieldMetadataInFlatObjectMetadataMapsWithOnlyFieldId({
fieldMetadataId: updateFieldInputInformalProperties.id,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
});
if (!isDefined(relatedFlatFieldMetadata)) {
return {
status: 'fail',
error: new FieldMetadataException(
'Field metadata to update not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
};
}
const flatObjectMetadataWithFlatFieldMaps =
existingFlatObjectMetadataMaps.byId[
relatedFlatFieldMetadata.objectMetadataId
];
if (!isDefined(flatObjectMetadataWithFlatFieldMaps)) {
return {
status: 'fail',
error: new FieldMetadataException(
'Field metadata to update object metadata not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
};
}
if (flatObjectMetadataWithFlatFieldMaps.isRemote) {
return {
status: 'fail',
error: new ObjectMetadataException(
'Remote objects are read-only',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
};
}
const isStandardField =
relatedFlatFieldMetadata.standardId !== null &&
!relatedFlatFieldMetadata.isCustom;
if (isStandardField) {
const invalidUpdatedProperties = Object.keys(updatedEditableFields).filter(
(property) =>
fieldMetadataStandardOverridesProperties.includes(
property as FieldMetadataStandardOverridesProperties,
),
);
if (invalidUpdatedProperties.length > 0) {
return {
status: 'fail',
error: new FieldMetadataException(
`Cannot edit standard field metadata properties: ${invalidUpdatedProperties.join(', ')}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
};
}
const updatedStandardFlatFieldMetadata =
fieldMetadataStandardOverridesProperties.reduce((acc, property) => {
const isPropertyUpdated = updatedEditableFields[property] !== undefined;
return {
...acc,
standardOverrides: {
...acc.standardOverrides,
...(isPropertyUpdated
? { [property]: updatedEditableFields[property] }
: {}),
},
};
}, relatedFlatFieldMetadata);
return {
status: 'success',
result: updatedStandardFlatFieldMetadata,
};
}
const updatedFlatFieldMetadata = fieldMetadataEditableProperties.reduce(
(acc, property) => {
const isPropertyUpdated = updatedEditableFields[property] !== undefined;
return {
...acc,
...(isPropertyUpdated
? { [property]: updatedEditableFields[property] }
: {}),
};
},
relatedFlatFieldMetadata,
);
return {
status: 'success',
result: updatedFlatFieldMetadata,
};
};
@@ -37,16 +37,16 @@ const getReservedCompositeFieldNames = (
export const validateFlatFieldMetadataNameAvailability = ({
name,
objectMetadata,
flatObjectMetadata,
}: {
name: string;
objectMetadata: FlatObjectMetadata;
flatObjectMetadata: FlatObjectMetadata;
}): FailedFlatFieldMetadataValidationExceptions | undefined => {
const reservedCompositeFieldsNames =
getReservedCompositeFieldNames(objectMetadata);
getReservedCompositeFieldNames(flatObjectMetadata);
if (
objectMetadata.flatFieldMetadatas.some(
flatObjectMetadata.flatFieldMetadatas.some(
(field) =>
field.name === name ||
(field.type === FieldMetadataType.RELATION && // Question: Should we also look for MORPH_RELATION field types ?
@@ -9,7 +9,7 @@ import {
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
export const validateObjectMetadataInputNamesOrThrow = <
T extends UpdateObjectPayload | CreateObjectInput,
@@ -1,6 +1,6 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
type ValidateMetadataNameTestContext = EachTestingContext<{
input: string;
@@ -0,0 +1,17 @@
import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils';
import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
export const validateMetadataNameOrThrow = (name: string): void => {
const validators = [
validateMetadataNameIsNotTooLongOrThrow,
validateMetadataNameIsNotTooShortOrThrow,
validateMetadataNameIsCamelCaseOrThrow,
validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow,
validateMetadataNameIsNotReservedKeywordOrThrow,
];
validators.forEach((validator) => validator(name));
};
@@ -1,17 +1,21 @@
import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils';
import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
export const validateMetadataNameOrThrow = (name: string): void => {
const validators = [
validateMetadataNameIsNotTooLongOrThrow,
validateMetadataNameIsNotTooShortOrThrow,
validateMetadataNameIsCamelCaseOrThrow,
validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow,
validateMetadataNameIsNotReservedKeywordOrThrow,
];
validators.forEach((validator) => validator(name));
export const validateMetadataName = (
name: string,
): undefined | FieldMetadataException => {
try {
validateMetadataNameOrThrow(name);
} catch (error) {
return new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
);
}
};