Field metadata and object metadata v1 relicas (#16230)

# Introduction

Related https://github.com/twentyhq/core-team-issues/issues/1911

Nearly done with all functional v1 implemen removal
Will remain dead code methods that I will detect using knip
This commit is contained in:
Paul Rastoin
2025-12-02 12:06:28 +01:00
committed by GitHub
parent 59f0f6f9db
commit eedb163131
45 changed files with 61 additions and 2504 deletions
@@ -15,14 +15,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadata/field-metadata.resolver';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-morph-relation.service';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
import { IsFieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-default-value.validator';
import { IsFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/validators/is-field-metadata-options.validator';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatFieldMetadataModule } from 'src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
@@ -38,6 +31,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataEntity } from './field-metadata.entity';
@@ -72,14 +66,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
IndexMetadataModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
services: [
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataMorphRelationService,
FieldMetadataRelationService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
],
services: [FieldMetadataService],
resolvers: [
{
EntityClass: FieldMetadataEntity,
@@ -104,22 +91,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
],
}),
],
providers: [
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataMorphRelationService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
FieldMetadataResolver,
],
exports: [
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataMorphRelationService,
FieldMetadataEnumValidationService,
FieldMetadataValidationService,
],
providers: [FieldMetadataService, FieldMetadataResolver],
exports: [FieldMetadataService],
})
export class FieldMetadataModule {}
@@ -1,22 +0,0 @@
import {
type FieldMetadataType,
type FieldMetadataOptions,
} from 'twenty-shared/types';
export interface CompositeProperty<
Type extends FieldMetadataType = FieldMetadataType,
> {
name: string;
description?: string;
type: Type;
hidden: 'input' | 'output' | true | false;
isRequired: boolean;
isIncludedInUniqueConstraint?: boolean;
isArray?: boolean;
options?: FieldMetadataOptions<Type>;
}
export interface CompositeType {
type: FieldMetadataType;
properties: CompositeProperty[];
}
@@ -1,304 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import {
type EnumFieldMetadataType,
FieldMetadataType,
type FieldMetadataOptions,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
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 { type EnumFieldMetadataUnionType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
type Validator<T> = {
validator: (str: T) => boolean;
message: MessageDescriptor;
};
type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput;
type ValidateEnumFieldMetadataArgs = {
existingFieldMetadata?: Pick<
FieldMetadataEntity,
'type' | 'isNullable' | 'defaultValue' | 'options'
>;
fieldMetadataInput: FieldMetadataUpdateCreateInput;
fieldMetadataType: EnumFieldMetadataUnionType;
};
const QUOTED_STRING_REGEX = /^['"](.*)['"]$/;
@Injectable()
export class FieldMetadataEnumValidationService {
constructor() {}
private validatorRunner<T>(
elementToValidate: T,
{ message, validator }: Validator<T>,
) {
const shouldThrow = validator(elementToValidate);
if (shouldThrow) {
throw new FieldMetadataException(
message.message ?? 'Invalid field input',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
}
private validateMetadataOptionId(sanitizedId?: string) {
const validators: Validator<string>[] = [
{
validator: (id) => !isDefined(id),
message: msg`Option id is required`,
},
{
validator: (id) => !z.string().uuid().safeParse(id).success,
message: msg`Option id is invalid`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedId, validator),
);
}
private validateMetadataOptionLabel(sanitizedLabel: string) {
const validators: Validator<string>[] = [
{
validator: (label) => !isDefined(label),
message: msg`Option label is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: msg`Option label exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: msg`Option label "${sanitizedLabel}" is beneath 1 character`,
},
{
validator: (label) => label.includes(','),
message: msg`Label must not contain a comma`,
},
{
validator: (label) => !isNonEmptyString(label) || label === ' ',
message: msg`Label must not be empty`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedLabel, validator),
);
}
private validateMetadataOptionValue(sanitizedValue: string) {
const validators: Validator<string>[] = [
{
validator: (value) => !isDefined(value),
message: msg`Option value is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: msg`Option value exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: msg`Option value "${sanitizedValue}" is beneath 1 character`,
},
{
validator: (value) => !isSnakeCaseString(value),
message: msg`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedValue, validator),
);
}
private validateDuplicates(
options: FieldMetadataOptions<EnumFieldMetadataType>,
) {
const fieldsToCheckForDuplicates = [
'position',
'id',
'value',
] as const satisfies (keyof (
| FieldMetadataDefaultOption[]
| FieldMetadataComplexOption[]
)[number])[];
const duplicatedValidators = fieldsToCheckForDuplicates.map<
Validator<FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]>
>((field) => ({
message: msg`Duplicated option ${field}`,
validator: () =>
new Set(options.map((option) => option[field])).size !== options.length,
}));
duplicatedValidators.forEach((validator) =>
this.validatorRunner(options, validator),
);
}
private validateFieldMetadataInputOptions(
fieldMetadataInput: FieldMetadataUpdateCreateInput,
) {
const { options } = fieldMetadataInput;
if (!isDefined(options) || options.length === 0) {
throw new FieldMetadataException(
'Options are required for enum fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
for (const option of options) {
this.validateMetadataOptionId(option.id);
this.validateMetadataOptionValue(option.value);
this.validateMetadataOptionLabel(option.label);
}
this.validateDuplicates(options);
}
private validateSelectDefaultValue(
options: FieldMetadataOptions<EnumFieldMetadataType>,
defaultValue: unknown,
) {
if (typeof defaultValue !== 'string') {
throw new FieldMetadataException(
'Default value for multi-select must be a stringified array',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const validators: Validator<string>[] = [
{
validator: (value: string) => !QUOTED_STRING_REGEX.test(value),
message: msg`Default value should be as quoted string`,
},
{
validator: (value: string) =>
!options.some(
(option) =>
option.value === value.replace(QUOTED_STRING_REGEX, '$1'),
),
message: msg`Default value "${defaultValue}" must be one of the option values`,
},
];
validators.forEach((validator) =>
this.validatorRunner(defaultValue, validator),
);
}
private validateMultiSelectDefaultValue(
options: FieldMetadataOptions<EnumFieldMetadataType>,
defaultValue: unknown,
) {
if (!Array.isArray(defaultValue)) {
throw new FieldMetadataException(
'Default value for multi-select must be an array',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
const validators: Validator<string[]>[] = [
{
validator: (values) => values.length === 0,
message: msg`If defined default value must contain at least one value`,
},
{
validator: (values) => new Set(values).size !== values.length,
message: msg`Default values must be unique`,
},
];
validators.forEach((validator) =>
this.validatorRunner(defaultValue, validator),
);
defaultValue.forEach((value) => {
this.validateSelectDefaultValue(options, value);
});
}
private validateFieldMetadataDefaultValue(
fieldType: EnumFieldMetadataType,
options: FieldMetadataOptions<EnumFieldMetadataType>,
defaultValue: unknown,
) {
switch (fieldType) {
case FieldMetadataType.SELECT:
this.validateSelectDefaultValue(options, defaultValue);
break;
case FieldMetadataType.MULTI_SELECT:
this.validateMultiSelectDefaultValue(options, defaultValue);
break;
case FieldMetadataType.RATING:
// TODO: Determine if RATING should be handled here
break;
default: {
assertUnreachable(
fieldType,
'Should never occur, unknown field metadata enum type',
);
}
}
}
async validateEnumFieldMetadataInput({
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
}: ValidateEnumFieldMetadataArgs) {
const isUpdate = isDefined(existingFieldMetadata);
const shouldSkipFieldMetadataInputOptionsValidation =
isUpdate && fieldMetadataInput.options === undefined;
if (!shouldSkipFieldMetadataInputOptionsValidation) {
this.validateFieldMetadataInputOptions(fieldMetadataInput);
}
if (isDefined(fieldMetadataInput.defaultValue)) {
const options =
fieldMetadataInput.options ?? existingFieldMetadata?.options;
if (!isDefined(options)) {
throw new FieldMetadataException(
'Should never occur, could not retrieve any options to validate default value',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
this.validateFieldMetadataDefaultValue(
fieldMetadataType,
options,
fieldMetadataInput.defaultValue,
);
}
}
}
@@ -1,172 +0,0 @@
import { Injectable } from '@nestjs/common';
import omit from 'lodash.omit';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
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 { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@Injectable()
export class FieldMetadataMorphRelationService {
constructor(
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
) {}
async createMorphRelationFieldMetadataItems({
fieldMetadataForCreate,
morphRelationsCreationPayload,
flatObjectMetadata,
fieldMetadataRepository,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}: {
fieldMetadataForCreate: CreateFieldInput;
morphRelationsCreationPayload: CreateFieldInput['morphRelationsCreationPayload'];
flatObjectMetadata: FlatObjectMetadata;
fieldMetadataRepository: Repository<FieldMetadataEntity>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<FieldMetadataEntity[]> {
if (
!isDefined(morphRelationsCreationPayload) ||
!Array.isArray(morphRelationsCreationPayload)
) {
throw new FieldMetadataException(
'Morph relations creation payload is not defined',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
if (morphRelationsCreationPayload.length < 1) {
throw new FieldMetadataException(
'Morph relations creation payload must not be empty',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
const fieldsCreated: FieldMetadataEntity[] = [];
const morphId = v4();
for (const relationCreationPayload of morphRelationsCreationPayload) {
const targetObjectMetadata =
flatObjectMetadataMaps.byId[
relationCreationPayload.targetObjectMetadataId
];
if (!isDefined(targetObjectMetadata)) {
throw new FieldMetadataException(
'Target object metadata does not exist in the object metadata maps',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const currentMorphRelationFieldName = computeMorphRelationFieldName({
fieldName: fieldMetadataForCreate.name,
relationType: relationCreationPayload.type,
targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
const relationFieldMetadataForCreate =
this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: {
...fieldMetadataForCreate,
name: currentMorphRelationFieldName,
},
relationCreationPayload: relationCreationPayload,
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: currentMorphRelationFieldName,
}),
},
);
await this.fieldMetadataRelationService.validateFieldMetadataRelationSpecifics(
{
fieldMetadataInput: relationFieldMetadataForCreate,
fieldMetadataType: relationFieldMetadataForCreate.type,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatObjectMetadata,
},
);
const createdMorphFieldMetadataItemWithoutTargetField =
await fieldMetadataRepository.save(
omit({ ...relationFieldMetadataForCreate, morphId }, 'id'),
);
const targetFieldMetadataName = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation(
{
objectMetadataId: relationCreationPayload.targetObjectMetadataId,
type: FieldMetadataType.RELATION,
name: targetFieldMetadataName,
label: relationCreationPayload.targetFieldLabel,
icon: relationCreationPayload.targetFieldIcon,
workspaceId: fieldMetadataForCreate.workspaceId,
settings: fieldMetadataForCreate.settings,
},
);
const targetFieldMetadataToCreateWithRelation =
this.fieldMetadataRelationService.computeCustomRelationFieldMetadataForCreation(
{
fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: {
targetObjectMetadataId: flatObjectMetadata.id,
targetFieldLabel: fieldMetadataForCreate.label,
targetFieldIcon: fieldMetadataForCreate.icon ?? 'Icon123',
type:
relationCreationPayload.type === RelationType.ONE_TO_MANY
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: targetFieldMetadataToCreate.name,
}),
},
);
// todo better type
const targetFieldMetadataToCreateWithRelationWithId = {
id: v4(),
...targetFieldMetadataToCreateWithRelation,
};
const targetFieldMetadata = await fieldMetadataRepository.save({
...targetFieldMetadataToCreateWithRelationWithId,
relationTargetFieldMetadataId:
createdMorphFieldMetadataItemWithoutTargetField.id,
});
const createdFieldMetadataItemUpdated =
await fieldMetadataRepository.save({
...createdMorphFieldMetadataItemWithoutTargetField,
relationTargetFieldMetadataId: targetFieldMetadata.id,
});
fieldsCreated.push(createdFieldMetadataItemUpdated, targetFieldMetadata);
}
return fieldsCreated;
}
}
@@ -1,267 +0,0 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { IsEnum, IsString, IsUUID } from 'class-validator';
import {
type FieldMetadataSettings,
FieldMetadataType,
RelationOnDeleteAction,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { computeMetadataNameFromLabel } from 'twenty-shared/metadata';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
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 { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util';
import { validateRelationCreationPayloadOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload-or-throw.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export class RelationCreationPayloadValidation {
@IsUUID()
targetObjectMetadataId?: string;
@IsString()
targetFieldLabel: string;
@IsString()
targetFieldIcon: string;
@IsEnum(RelationType)
type: RelationType;
}
type ValidateFieldMetadataArgs<T extends UpdateFieldInput | CreateFieldInput> =
{
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: T;
flatObjectMetadata: FlatObjectMetadata;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
existingFieldMetadata?: FieldMetadataEntity;
};
@Injectable()
export class FieldMetadataRelationService {
constructor() {}
async createRelationFieldMetadataItems({
fieldMetadataInput,
flatObjectMetadata,
fieldMetadataRepository,
}: {
fieldMetadataInput: CreateFieldInput;
flatObjectMetadata: FlatObjectMetadata;
fieldMetadataRepository: Repository<FieldMetadataEntity>;
}): Promise<FieldMetadataEntity[]> {
const createdFieldMetadataItem =
await fieldMetadataRepository.save(fieldMetadataInput);
const relationCreationPayload = fieldMetadataInput.relationCreationPayload;
if (!isDefined(relationCreationPayload)) {
throw new FieldMetadataException(
'Relation creation payload is not defined',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
const targetFieldMetadataName = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
const targetFieldMetadataToCreate = prepareCustomFieldMetadataForCreation({
objectMetadataId: relationCreationPayload.targetObjectMetadataId,
type: fieldMetadataInput.type,
name: targetFieldMetadataName,
label: relationCreationPayload.targetFieldLabel,
icon: relationCreationPayload.targetFieldIcon,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
});
const targetFieldMetadataToCreateWithRelation =
this.computeCustomRelationFieldMetadataForCreation({
fieldMetadataInput: targetFieldMetadataToCreate,
relationCreationPayload: {
targetObjectMetadataId: flatObjectMetadata.id,
targetFieldLabel: fieldMetadataInput.label,
targetFieldIcon: fieldMetadataInput.icon ?? 'Icon123',
type:
relationCreationPayload.type === RelationType.ONE_TO_MANY
? RelationType.MANY_TO_ONE
: RelationType.ONE_TO_MANY,
},
joinColumnName: computeMorphOrRelationFieldJoinColumnName({
name: targetFieldMetadataToCreate.name,
}),
});
// todo better type
const targetFieldMetadataToCreateWithRelationWithId = {
id: v4(),
...targetFieldMetadataToCreateWithRelation,
};
const targetFieldMetadata = await fieldMetadataRepository.save({
...targetFieldMetadataToCreateWithRelationWithId,
relationTargetFieldMetadataId: createdFieldMetadataItem.id,
});
const createdFieldMetadataItemUpdated = await fieldMetadataRepository.save({
...createdFieldMetadataItem,
relationTargetFieldMetadataId: targetFieldMetadata.id,
});
return [createdFieldMetadataItemUpdated, targetFieldMetadata];
}
async validateFieldMetadataRelationSpecifics<
T extends UpdateFieldInput | CreateFieldInput,
>({
fieldMetadataInput,
fieldMetadataType,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
flatObjectMetadata,
}: Pick<
ValidateFieldMetadataArgs<T>,
| 'fieldMetadataInput'
| 'fieldMetadataType'
| 'flatObjectMetadataMaps'
| 'flatFieldMetadataMaps'
| 'flatObjectMetadata'
>): Promise<T> {
// TODO: clean typings, we should try to validate both update and create inputs in the same function
const isRelation =
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION;
if (
isRelation &&
isDefined(
(fieldMetadataInput as unknown as CreateFieldInput)
.relationCreationPayload,
)
) {
validateFieldNameAvailabilityOrThrow({
name: `${fieldMetadataInput.name}Id`,
flatObjectMetadata,
flatFieldMetadataMaps,
});
const relationCreationPayload = (
fieldMetadataInput as unknown as CreateFieldInput
).relationCreationPayload;
if (isDefined(relationCreationPayload)) {
await validateRelationCreationPayloadOrThrow(relationCreationPayload);
const computedMetadataNameFromLabel = computeMetadataNameFromLabel(
relationCreationPayload.targetFieldLabel,
);
validateMetadataNameOrThrow(computedMetadataNameFromLabel);
const targetFlatObjectMetadata =
flatObjectMetadataMaps.byId[
relationCreationPayload.targetObjectMetadataId
];
if (!isDefined(targetFlatObjectMetadata)) {
throw new FieldMetadataException(
`Object metadata relation target not found for relation creation payload`,
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
);
}
validateFieldNameAvailabilityOrThrow({
name: computedMetadataNameFromLabel,
flatObjectMetadata: targetFlatObjectMetadata,
flatFieldMetadataMaps,
});
validateFieldNameAvailabilityOrThrow({
name: `${computedMetadataNameFromLabel}Id`,
flatObjectMetadata: targetFlatObjectMetadata,
flatFieldMetadataMaps,
});
if (
computedMetadataNameFromLabel === fieldMetadataInput.name &&
flatObjectMetadata.id === targetFlatObjectMetadata.id
) {
throw new FieldMetadataException(
`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: msg`Name "${computedMetadataNameFromLabel}" cannot be the same on both side of the relation`,
},
);
}
}
}
return fieldMetadataInput;
}
// TODO refactor and strictly type
computeCustomRelationFieldMetadataForCreation({
fieldMetadataInput,
relationCreationPayload,
joinColumnName,
}: {
fieldMetadataInput: CreateFieldInput;
relationCreationPayload: CreateFieldInput['relationCreationPayload'];
joinColumnName: string;
}) {
const isRelation =
isFieldMetadataEntityOfType(
fieldMetadataInput,
FieldMetadataType.RELATION,
) ||
isFieldMetadataEntityOfType(
fieldMetadataInput,
FieldMetadataType.MORPH_RELATION,
);
const defaultIcon = 'IconRelationOneToMany';
const isManyToOne =
isRelation && relationCreationPayload?.type === RelationType.MANY_TO_ONE;
const settings = isManyToOne
? {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.SET_NULL,
joinColumnName,
}
: {
...(fieldMetadataInput.settings as FieldMetadataSettings<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>),
relationType: RelationType.ONE_TO_MANY,
};
return {
...fieldMetadataInput,
icon: fieldMetadataInput.icon ?? defaultIcon,
relationCreationPayload,
relationTargetObjectMetadataId:
relationCreationPayload?.targetObjectMetadataId,
settings,
};
}
}
@@ -1,254 +0,0 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { type ClassConstructor, plainToInstance } from 'class-transformer';
import {
IsArray,
IsEnum,
IsInt,
IsOptional,
Max,
Min,
type ValidationError,
validateOrReject,
} from 'class-validator';
import { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from 'twenty-shared/constants';
import {
ALLOWED_ADDRESS_SUBFIELDS,
type AllowedAddressSubField,
FieldMetadataType,
type FieldMetadataSettings,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
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 { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
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-or-throw.utils';
type ValidateFieldMetadataArgs = {
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: CreateFieldInput | UpdateFieldInput;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
existingFieldMetadata?: FieldMetadataEntity;
};
enum ValueType {
PERCENTAGE = 'percentage',
NUMBER = 'number',
SHORT_NUMBER = 'shortNumber',
}
class NumberSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
decimals?: number;
@IsOptional()
@IsEnum(ValueType)
type?: 'percentage' | 'number' | 'shortNumber';
}
class TextSettingsValidation {
@IsOptional()
@IsInt()
@Min(0)
@Max(100)
displayedMaxRows?: number;
}
class AddressSettingsValidation {
@IsOptional()
@IsArray()
@IsEnum(ALLOWED_ADDRESS_SUBFIELDS, { each: true })
subFields?: AllowedAddressSubField[];
}
class MultipleValuesSettingsValidation {
@IsOptional()
@IsInt()
@Min(MULTI_ITEM_FIELD_MIN_MAX_VALUES)
maxNumberOfValues?: number;
}
@Injectable()
export class FieldMetadataValidationService {
constructor(
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
) {}
async validateSettingsOrThrow<T extends FieldMetadataType>({
fieldType,
settings,
}: {
fieldType: FieldMetadataType;
settings: FieldMetadataSettings<T>;
}) {
switch (fieldType) {
case FieldMetadataType.NUMBER:
await this.validateSettings({
type: FieldMetadataType.NUMBER,
validator: NumberSettingsValidation,
settings,
});
break;
case FieldMetadataType.TEXT:
await this.validateSettings({
type: FieldMetadataType.TEXT,
validator: TextSettingsValidation,
settings,
});
break;
case FieldMetadataType.ADDRESS:
await this.validateSettings({
type: FieldMetadataType.ADDRESS,
validator: AddressSettingsValidation,
settings,
});
break;
case FieldMetadataType.PHONES:
case FieldMetadataType.EMAILS:
case FieldMetadataType.LINKS:
case FieldMetadataType.ARRAY:
await this.validateSettings({
type: fieldType,
validator: MultipleValuesSettingsValidation,
settings,
});
break;
default:
break;
}
}
private async validateSettings<
Type extends FieldMetadataType,
TValidator extends ClassConstructor<object>,
TFieldMetadataType extends FieldMetadataType,
>({
type: _type,
settings,
validator,
}: {
validator: TValidator;
settings: FieldMetadataSettings<Type>;
type: TFieldMetadataType;
}) {
try {
const settingsInstance = plainToInstance(validator, settings);
await validateOrReject(settingsInstance);
} catch (error) {
const errorMessages = Array.isArray(error)
? error
.map((err: ValidationError) => Object.values(err.constraints ?? {}))
.flat()
.join(', ')
: error.message;
throw new FieldMetadataException(
`Value for settings is invalid: ${errorMessages}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
async validateFieldMetadata({
fieldMetadataInput,
fieldMetadataType,
flatObjectMetadata,
flatFieldMetadataMaps,
existingFieldMetadata,
}: ValidateFieldMetadataArgs): Promise<void> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
);
}
throw error;
}
try {
validateFieldNameAvailabilityOrThrow({
name: fieldMetadataInput.name,
flatObjectMetadata,
flatFieldMetadataMaps,
});
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new FieldMetadataException(
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: msg`Name is not available, it may be duplicating another field's name.`,
},
);
}
throw error;
}
}
if (fieldMetadataInput.isNullable === false) {
if (!isDefined(fieldMetadataInput.defaultValue)) {
throw new FieldMetadataException(
'Default value is required for non nullable fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (isEnumFieldMetadataType(fieldMetadataType)) {
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
{
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
},
);
}
if (fieldMetadataInput.settings) {
await this.validateSettingsOrThrow({
fieldType: fieldMetadataType,
settings: fieldMetadataInput.settings,
});
}
const isRelationField =
fieldMetadataType === FieldMetadataType.RELATION ||
fieldMetadataType === FieldMetadataType.MORPH_RELATION;
if (
isRelationField &&
isDefined(existingFieldMetadata) &&
isDefined(fieldMetadataInput.name) &&
fieldMetadataInput.name !== existingFieldMetadata.name
) {
throw new FieldMetadataException(
'Name cannot be changed for relation fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
}
@@ -1,11 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`checkCanDeactivateFieldOrThrow throws if trying to deactivate createdAt field 1`] = `"Cannot deactivate createdAt, updatedAt or deletedAt field"`;
exports[`checkCanDeactivateFieldOrThrow throws if trying to deactivate deletedAt field 1`] = `"Cannot deactivate createdAt, updatedAt or deletedAt field"`;
exports[`checkCanDeactivateFieldOrThrow throws if trying to deactivate label identifier field 1`] = `"Cannot deactivate label identifier field"`;
exports[`checkCanDeactivateFieldOrThrow throws if trying to deactivate system field 1`] = `"Cannot deactivate system field"`;
exports[`checkCanDeactivateFieldOrThrow throws if trying to deactivate updatedAt field 1`] = `"Cannot deactivate createdAt, updatedAt or deletedAt field"`;
@@ -1,38 +0,0 @@
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
describe('assertDoesNotNullifyDefaultValueForNonNullableField', () => {
it('should not throw if default value is set to null and field is nullable', () => {
expect(() =>
assertDoesNotNullifyDefaultValueForNonNullableField({
isNullable: true,
defaultValueFromUpdate: null,
}),
).not.toThrow();
});
it('should not throw if default value is undefined and field is non nullable', () => {
expect(() =>
assertDoesNotNullifyDefaultValueForNonNullableField({
isNullable: false,
}),
).not.toThrow();
});
it('should not throw if default value is not set to null and field is non nullable', () => {
expect(() =>
assertDoesNotNullifyDefaultValueForNonNullableField({
isNullable: false,
defaultValueFromUpdate: 'new default value',
}),
).not.toThrow();
});
it('should throw if default value is set to null and field is non nullable', () => {
expect(() =>
assertDoesNotNullifyDefaultValueForNonNullableField({
isNullable: false,
defaultValueFromUpdate: null,
}),
).toThrow();
});
});
@@ -1,106 +0,0 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
type CheckCanDeactivateFieldOrThrowTestContext = EachTestingContext<{
input: Parameters<typeof checkCanDeactivateFieldOrThrow>[0];
shouldNotThrow?: true;
}>;
const checkCanDeactivateFieldOrThrowTestCases: CheckCanDeactivateFieldOrThrowTestContext[] =
[
{
title: 'does not throw if nominal case',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldIdentifierId',
existingFieldMetadata: {
id: 'myFieldId',
isSystem: false,
name: 'myFieldName',
},
},
shouldNotThrow: true,
},
},
{
title: 'throws if trying to deactivate label identifier field',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldId',
existingFieldMetadata: {
id: 'fieldId',
isSystem: false,
name: 'name',
},
},
},
},
{
title: 'throws if trying to deactivate system field',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldIdentifierId',
existingFieldMetadata: {
id: 'systemFieldId',
isSystem: true,
name: 'systemField',
},
},
},
},
{
title: 'throws if trying to deactivate createdAt field',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldIdentifierId',
existingFieldMetadata: {
id: 'createdAtId',
isSystem: false,
name: 'createdAt',
},
},
},
},
{
title: 'throws if trying to deactivate updatedAt field',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldIdentifierId',
existingFieldMetadata: {
id: 'updatedAtId',
isSystem: false,
name: 'updatedAt',
},
},
},
},
{
title: 'throws if trying to deactivate deletedAt field',
context: {
input: {
labelIdentifierFieldMetadataId: 'fieldIdentifierId',
existingFieldMetadata: {
id: 'deletedAtId',
isSystem: false,
name: 'deletedAt',
},
},
},
},
];
describe('checkCanDeactivateFieldOrThrow', () => {
it.each(checkCanDeactivateFieldOrThrowTestCases)(
'$title',
({ context: { input, shouldNotThrow } }) => {
if (shouldNotThrow) {
expect(() => checkCanDeactivateFieldOrThrow(input)).not.toThrow();
} else {
expect(() =>
checkCanDeactivateFieldOrThrow(input),
).toThrowErrorMatchingSnapshot();
}
},
);
});
@@ -1,85 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import {
type FieldMetadataSettings,
FieldMetadataType,
} from 'twenty-shared/types';
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service';
describe('FieldMetadataValidationService', () => {
let service: FieldMetadataValidationService;
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FieldMetadataValidationService,
{
provide: FieldMetadataEnumValidationService,
useValue: {},
},
],
}).compile();
service = module.get<FieldMetadataValidationService>(
FieldMetadataValidationService,
);
});
it('should validate NUMBER settings successfully', async () => {
const settings: FieldMetadataSettings<FieldMetadataType.NUMBER> = {
decimals: 2,
type: 'number',
};
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.NUMBER,
settings,
}),
).resolves.not.toThrow();
});
it('should throw an error for invalid NUMBER settings', async () => {
const settings: FieldMetadataSettings<FieldMetadataType.NUMBER> = {
// @ts-expect-error expected invalid payload below
type: 'invalidType',
};
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.NUMBER,
settings,
}),
).rejects.toThrow(FieldMetadataException);
});
it('should validate TEXT settings successfully', async () => {
const settings: FieldMetadataSettings<FieldMetadataType.TEXT> = {
displayedMaxRows: 10,
};
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.TEXT,
settings,
}),
).resolves.not.toThrow();
});
it('should throw an error for invalid TEXT settings', async () => {
const settings: FieldMetadataSettings<FieldMetadataType.TEXT> = {
// @ts-expect-error expected invalid payload below
displayedMaxRows: 'NotANumber',
};
await expect(
service.validateSettingsOrThrow({
fieldType: FieldMetadataType.TEXT,
settings,
}),
).rejects.toThrow(FieldMetadataException);
});
});
@@ -1,12 +0,0 @@
import { type FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
export const areFieldMetadatasOfType = <T extends FieldMetadataType>(
fieldMetadatas: FieldMetadataEntity[],
fieldMetadataType: T,
): fieldMetadatas is Array<FieldMetadataEntity & FieldMetadataEntity<T>> =>
fieldMetadatas.every((fieldMetadata) =>
isFlatFieldMetadataOfType(fieldMetadata, fieldMetadataType),
);
@@ -1,20 +0,0 @@
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
export const assertDoesNotNullifyDefaultValueForNonNullableField = ({
isNullable,
defaultValueFromUpdate,
}: {
isNullable: boolean | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
defaultValueFromUpdate?: any;
}) => {
if (!isNullable && defaultValueFromUpdate === null) {
throw new FieldMetadataException(
'Default value cannot be nullified for non-nullable field',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
};
@@ -1,42 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldStandardOverridesDTO } 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 { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const buildUpdatableStandardFieldInput = (
fieldMetadataInput: UpdateFieldInput,
existingFieldMetadata: Pick<
FieldMetadataEntity,
'type' | 'isNullable' | 'defaultValue' | 'options'
>,
) => {
const updatableStandardFieldInput: UpdateFieldInput & {
standardOverrides?: FieldStandardOverridesDTO;
} = {
id: fieldMetadataInput.id,
isActive: fieldMetadataInput.isActive,
workspaceId: fieldMetadataInput.workspaceId,
defaultValue: fieldMetadataInput.defaultValue,
settings: fieldMetadataInput.settings,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
isUnique: fieldMetadataInput.isUnique,
};
if ('standardOverrides' in fieldMetadataInput) {
updatableStandardFieldInput.standardOverrides =
fieldMetadataInput.standardOverrides as FieldStandardOverridesDTO;
}
if (
existingFieldMetadata.type === FieldMetadataType.SELECT ||
existingFieldMetadata.type === FieldMetadataType.MULTI_SELECT
) {
return {
...updatableStandardFieldInput,
options: fieldMetadataInput.options,
};
}
return updatableStandardFieldInput;
};
@@ -1,38 +0,0 @@
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';
type CheckCanDeactivateFieldOptions = {
labelIdentifierFieldMetadataId: string;
existingFieldMetadata: Pick<FieldMetadataEntity, 'id' | 'isSystem' | 'name'>;
};
export const checkCanDeactivateFieldOrThrow = ({
labelIdentifierFieldMetadataId,
existingFieldMetadata,
}: CheckCanDeactivateFieldOptions) => {
if (existingFieldMetadata.id === labelIdentifierFieldMetadataId) {
throw new FieldMetadataException(
'Cannot deactivate label identifier field',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
if (existingFieldMetadata.isSystem === true) {
throw new FieldMetadataException(
'Cannot deactivate system field',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
if (
['deletedAt', 'createdAt', 'updatedAt'].includes(existingFieldMetadata.name)
) {
throw new FieldMetadataException(
'Cannot deactivate createdAt, updatedAt or deletedAt field',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
};
@@ -1,10 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const isFieldMetadataTypeMorphRelation = (
fieldMetadata: FieldMetadataEntity,
): fieldMetadata is FieldMetadataEntity &
FieldMetadataEntity<FieldMetadataType.MORPH_RELATION> => {
return fieldMetadata.type === FieldMetadataType.MORPH_RELATION;
};
@@ -1,6 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
export const isSelectFieldMetadataType = (
type: FieldMetadataType,
): type is FieldMetadataType.SELECT => {
return type === FieldMetadataType.SELECT;
};
@@ -1,15 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export type SelectOrMultiSelectFieldMetadataEntity = FieldMetadataEntity<
FieldMetadataType.SELECT | FieldMetadataType.MULTI_SELECT
>;
export const isSelectOrMultiSelectFieldMetadata = (
fieldMetadata: FieldMetadataEntity,
): fieldMetadata is FieldMetadataEntity &
SelectOrMultiSelectFieldMetadataEntity => {
return [FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT].includes(
fieldMetadata.type,
);
};
@@ -1,23 +0,0 @@
import { v4 } from 'uuid';
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import {
type FieldMetadataComplexOption,
type FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const prepareCustomFieldMetadataOptions = (
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
): undefined | Pick<FieldMetadataEntity, 'options'> => {
return {
options: options.map((option) => ({
id: v4(),
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(option, [
'label',
'value',
'id',
]),
})),
};
};
@@ -1,42 +0,0 @@
import { v4 } from 'uuid';
import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { generateDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/generate-default-value';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
import { prepareCustomFieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/utils/prepare-custom-field-metadata-for-options.util';
export const prepareCustomFieldMetadataForCreation = (
fieldMetadataInput: CreateFieldInput,
) => {
const options = fieldMetadataInput.options
? prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValue =
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type);
return {
id: v4(),
createdAt: new Date(),
updatedAt: new Date(),
name: fieldMetadataInput.name,
label: fieldMetadataInput.label,
icon: fieldMetadataInput.icon,
type: fieldMetadataInput.type,
isLabelSyncedWithName: fieldMetadataInput.isLabelSyncedWithName,
objectMetadataId: fieldMetadataInput.objectMetadataId,
workspaceId: fieldMetadataInput.workspaceId,
isNullable: generateNullable(
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
relationTargetObjectMetadataId:
fieldMetadataInput?.relationCreationPayload?.targetObjectMetadataId,
defaultValue,
...options,
isUnique: fieldMetadataInput.isUnique ?? false,
isActive: true,
isCustom: true,
settings: fieldMetadataInput.settings,
};
};
@@ -1,11 +1,31 @@
import { plainToInstance } from 'class-transformer';
import { validateOrReject, type ValidationError } from 'class-validator';
import {
IsEnum,
IsString,
IsUUID,
validateOrReject,
type ValidationError,
} from 'class-validator';
import { RelationType } from 'twenty-shared/types';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { RelationCreationPayloadValidation } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service';
class RelationCreationPayloadValidation {
@IsUUID()
targetObjectMetadataId?: string;
@IsString()
targetFieldLabel: string;
@IsString()
targetFieldIcon: string;
@IsEnum(RelationType)
type: RelationType;
}
export const validateRelationCreationPayloadOrThrow = async (
relationCreationPayload: RelationCreationPayloadValidation,
@@ -1,79 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
import { type FieldMetadataType } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { LoggerService } from 'src/engine/core-modules/logger/logger.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataDefaultValue', async: true })
export class IsFieldMetadataDefaultValue
implements ValidatorConstraintInterface
{
constructor(
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly loggerService: LoggerService,
) {}
async validate(
value: FieldMetadataDefaultValue,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
// @ts-expect-error legacy noImplicitAny
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
// @ts-expect-error legacy noImplicitAny
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
where: {
id,
},
});
} catch {
return false;
}
type = fieldMetadata.type;
}
const validationResult = validateDefaultValueForType(type, value);
if (!validationResult.isValid) {
this.loggerService.error(
{
message: 'Error during field validation',
errors: validationResult.errors,
},
'Field Metadata Validation',
);
}
return validationResult.isValid;
}
defaultMessage(): string {
return 'FieldMetadataDefaultValue is not valid';
}
}
@@ -1,62 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ValidationArguments, ValidatorConstraint } from 'class-validator';
import {
type FieldMetadataType,
type FieldMetadataOptions,
} from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { validateOptionsForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-options-for-type.util';
@Injectable()
@ValidatorConstraint({ name: 'isFieldMetadataOptions', async: true })
export class IsFieldMetadataOptions {
private validationErrors: string[] = [];
constructor(
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
) {}
async validate(
value: FieldMetadataOptions,
args: ValidationArguments,
): Promise<boolean> {
// Try to extract type value from the object
// @ts-expect-error legacy noImplicitAny
let type: FieldMetadataType | null = args.object['type'];
if (!type) {
// Extract id value from the instance, should happen only when updating
// @ts-expect-error legacy noImplicitAny
const id: string | undefined = args.instance?.['id'];
if (!id) {
return false;
}
let fieldMetadata: FieldMetadataEntity;
try {
fieldMetadata = await this.fieldMetadataRepository.findOneOrFail({
where: { id },
});
} catch {
return false;
}
type = fieldMetadata.type;
}
try {
return validateOptionsForType(type, value);
} catch (err) {
this.validationErrors.push(err.message);
return false;
}
}
}
@@ -1,4 +1,5 @@
import { t, msg } from '@lingui/core/macro';
import { msg, t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
@@ -7,7 +8,30 @@ import { areFlatObjectMetadataNamesSyncedWithLabels } from 'src/engine/metadata-
import { validateFlatObjectMetadataLabel } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util';
import { validateFlatObjectMetadataNames } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { doesOtherObjectWithSameNameExists } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-or-throw.util';
type ValidateNoOtherObjectWithSameNameExistsOrThrowsParams = {
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
existingObjectMetadataId?: string;
objectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const doesOtherObjectWithSameNameExists = ({
objectMetadataMaps,
objectMetadataNamePlural,
objectMetadataNameSingular,
existingObjectMetadataId,
}: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams) =>
Object.values(objectMetadataMaps.byId)
.filter(isDefined)
.some(
(objectMetadata) =>
(objectMetadata.nameSingular === objectMetadataNameSingular ||
objectMetadata.namePlural === objectMetadataNamePlural ||
objectMetadata.nameSingular === objectMetadataNamePlural ||
objectMetadata.namePlural === objectMetadataNameSingular) &&
objectMetadata.id !== existingObjectMetadataId,
);
export const validateFlatObjectMetadataNameAndLabels = ({
optimisticFlatObjectMetadataMaps,
@@ -1,19 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateObjectMetadataInputOrThrow should fail when name exceeds maximum length 1`] = `"Name is too long: it exceeds the 63 characters limit."`;
exports[`validateObjectMetadataInputOrThrow should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`;
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is an empty string 1`] = `"Input is too short: """`;
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is not camelCased 1`] = `"Not_Camel_Case should be in camelCase"`;
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`;
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is an empty string 1`] = `"Input is too short: """`;
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is not camelCased 1`] = `"Not_Camel_Case should be in camelCase"`;
@@ -1,57 +0,0 @@
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { type EachTestingContext } from 'twenty-shared/testing';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { validateObjectMetadataInputNamesOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
type ValidateObjectNameTestingContext = EachTestingContext<
Partial<UpdateObjectPayload>
>;
const validateObjectMetadataTestCases: ValidateObjectNameTestingContext[] = [
{
title: 'when nameSingular has invalid characters',
context: { nameSingular: 'μ' },
},
{
title: 'when namePlural has invalid characters',
context: { namePlural: 'μ' },
},
{
title: 'when nameSingular is a reserved keyword',
context: { nameSingular: 'user' },
},
{
title: 'when namePlural is a reserved keyword',
context: { namePlural: 'users' },
},
{
title: 'when nameSingular is not camelCased',
context: { nameSingular: 'Not_Camel_Case' },
},
{
title: 'when namePlural is not camelCased',
context: { namePlural: 'Not_Camel_Case' },
},
{
title: 'when namePlural is an empty string',
context: { namePlural: '' },
},
{
title: 'when nameSingular is an empty string',
context: { nameSingular: '' },
},
{
title: 'when name exceeds maximum length',
context: { nameSingular: 'a'.repeat(64) },
},
];
describe('validateObjectMetadataInputOrThrow should fail', () => {
it.each(validateObjectMetadataTestCases)('$title', ({ context }) => {
expect(() =>
validateObjectMetadataInputNamesOrThrow(
getMockCreateObjectInput(context),
),
).toThrowErrorMatchingSnapshot();
});
});
@@ -1,15 +0,0 @@
export const buildNameLabelAndDescriptionForForeignKeyFieldMetadata = ({
targetObjectNameSingular,
targetObjectLabelSingular,
relatedObjectLabelSingular,
}: {
targetObjectNameSingular: string;
targetObjectLabelSingular: string;
relatedObjectLabelSingular: string;
}) => {
const name = `${targetObjectNameSingular}Id`;
const label = `${targetObjectLabelSingular} ID (foreign key)`;
const description = `${relatedObjectLabelSingular} ${targetObjectLabelSingular} id foreign key`;
return { name, label, description };
};
@@ -1,95 +0,0 @@
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { type UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
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-or-throw.utils';
export const validateObjectMetadataInputNamesOrThrow = <
T extends UpdateObjectPayload | CreateObjectInput,
>({
namePlural,
nameSingular,
}: T): void =>
[namePlural, nameSingular].forEach((name) => {
if (!isDefined(name)) {
return;
}
validateObjectMetadataInputNameOrThrow(name);
});
export const validateObjectMetadataInputNameOrThrow = (name: string): void => {
try {
validateMetadataNameOrThrow(name);
} catch (error) {
if (error instanceof InvalidMetadataException) {
const errorMessage = error.message;
throw new ObjectMetadataException(
errorMessage,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
{
userFriendlyMessage: msg`Invalid object metadata input`,
},
);
}
throw error;
}
};
export const validateObjectMetadataInputLabelsOrThrow = <
T extends CreateObjectInput,
>({
labelPlural,
labelSingular,
}: T): void =>
[labelPlural, labelSingular].forEach((label) =>
validateObjectMetadataInputLabelOrThrow(label),
);
const validateObjectMetadataInputLabelOrThrow = (name: string): void => {
const validators = [
validateMetadataNameIsNotTooShortOrThrow,
validateMetadataNameIsNotTooLongOrThrow,
];
try {
validators.forEach((validator) => validator(name.trim()));
} catch (error) {
if (error instanceof InvalidMetadataException) {
throw new ObjectMetadataException(
error.message,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
);
}
throw error;
}
};
type ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs = {
inputs: [string, string];
message: string;
};
export const validateLowerCasedAndTrimmedStringsAreDifferentOrThrow = ({
message,
inputs: [firstString, secondString],
}: ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs) => {
if (firstString.trim().toLowerCase() === secondString.trim().toLowerCase()) {
throw new ObjectMetadataException(
message,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
};
@@ -1,11 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateFieldNameAvailabilityOrThrow throws error with ACTOR suffixes 1`] = `"Name "fieldActorName" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with ADDRESS suffixes 1`] = `"Name "fieldAddressAddressStreet1" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with CURRENCY suffixes 1`] = `"Name "fieldCurrencyAmountMicros" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with FULL_NAME suffixes 1`] = `"Name "fieldFullNameFirstName" is not available"`;
exports[`validateFieldNameAvailabilityOrThrow throws error with LINKS suffixes 1`] = `"Name "fieldLinksPrimaryLinkLabel" is not available"`;
@@ -1,17 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`validateMetadataNameOrThrow throw error when string is not in camel case 1`] = `"TestName should be in camelCase"`;
exports[`validateMetadataNameOrThrow throws error when starts with digits 1`] = `"123string should be in camelCase"`;
exports[`validateMetadataNameOrThrow throws error when string has non latin characters 1`] = `"String "בְרִבְרִ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
exports[`validateMetadataNameOrThrow throws error when string has spaces 1`] = `"name with spaces should be in camelCase"`;
exports[`validateMetadataNameOrThrow throws error when string is a reserved word 1`] = `"The name "role" is not available"`;
exports[`validateMetadataNameOrThrow throws error when string is above 63 characters 1`] = `"Name is too long: it exceeds the 63 characters limit."`;
exports[`validateMetadataNameOrThrow throws error when string is empty 1`] = `"Input is too short: """`;
exports[`validateMetadataNameOrThrow throws error when string starts with capital letter 1`] = `"StringStartingWithCapitalLetter should be in camelCase"`;
@@ -1,6 +1,6 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { computeMetadataNameFromLabelOrThrow } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label-or-throw.util';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
@@ -124,7 +124,7 @@ describe('computeMetadataNameFromLabel', () => {
describe('successful cases', () => {
it.each(successfulTestCases)('$title', ({ context }) => {
const result = computeMetadataNameFromLabel(context.input);
const result = computeMetadataNameFromLabelOrThrow(context.input);
expect(result).toBe(context.expected);
});
@@ -132,7 +132,7 @@ describe('computeMetadataNameFromLabel', () => {
describe('failing cases', () => {
it.each(failingTestCases)('$title', ({ context }) => {
expect(() => computeMetadataNameFromLabel(context.input)).toThrow(
expect(() => computeMetadataNameFromLabelOrThrow(context.input)).toThrow(
context.expectToThrow?.error,
);
});
@@ -1,180 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type EachTestingContext } from 'twenty-shared/testing';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
const FIELD_LINKS_MOCK_NAME = 'fieldLinks';
const FIELD_CURRENCY_MOCK_NAME = 'fieldCurrency';
const FIELD_ADDRESS_MOCK_NAME = 'fieldAddress';
const FIELD_ACTOR_MOCK_NAME = 'fieldActor';
const FIELD_FULL_NAME_MOCK_NAME = 'fieldFullName';
const objectMetadataId = '20202020-0000-0000-0000-000000000001';
const workspaceId = '20202020-0000-0000-0000-000000000000';
const createFlatFieldMetadata = (
id: string,
name: string,
type: FieldMetadataType,
): FlatFieldMetadata => {
return getFlatFieldMetadataMock({
id,
name,
type,
objectMetadataId,
universalIdentifier: id,
workspaceId,
});
};
const fieldLinksMock = createFlatFieldMetadata(
'fieldLinksId',
FIELD_LINKS_MOCK_NAME,
FieldMetadataType.LINKS,
);
const fieldCurrencyMock = createFlatFieldMetadata(
'fieldCurrencyId',
FIELD_CURRENCY_MOCK_NAME,
FieldMetadataType.CURRENCY,
);
const fieldFullNameMock = createFlatFieldMetadata(
'fieldFullNameId',
FIELD_FULL_NAME_MOCK_NAME,
FieldMetadataType.FULL_NAME,
);
const fieldActorMock = createFlatFieldMetadata(
'fieldActorId',
FIELD_ACTOR_MOCK_NAME,
FieldMetadataType.ACTOR,
);
const fieldAddressMock = createFlatFieldMetadata(
'fieldAddressId',
FIELD_ADDRESS_MOCK_NAME,
FieldMetadataType.ADDRESS,
);
const FIELDS_MOCK = [
fieldLinksMock,
fieldCurrencyMock,
fieldFullNameMock,
fieldActorMock,
fieldAddressMock,
];
const flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata> =
FIELDS_MOCK.reduce(
(acc, field) => ({
...acc,
byId: {
...acc.byId,
[field.id]: field,
},
}),
createEmptyFlatEntityMaps() as FlatEntityMaps<FlatFieldMetadata>,
);
const flatObjectMetadata = getFlatObjectMetadataMock({
id: objectMetadataId,
workspaceId,
nameSingular: 'objectName',
namePlural: 'objectNames',
labelSingular: 'Object Name',
labelPlural: 'Object Names',
description: 'Object description',
icon: 'Icon123',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: true,
isSearchable: true,
fieldMetadataIds: FIELDS_MOCK.map((f) => f.id),
indexMetadataIds: [],
viewIds: [],
universalIdentifier: objectMetadataId,
applicationId: null,
labelIdentifierFieldMetadataId: null,
imageIdentifierFieldMetadataId: null,
shortcut: null,
isLabelSyncedWithName: true,
standardId: null,
standardOverrides: null,
targetTableName: 'DEPRECATED',
duplicateCriteria: null,
});
type ValidateFieldNameAvailabilityTestContext = EachTestingContext<{
input: string;
shouldNotThrow?: true;
}>;
const validateFieldNameAvailabilityTestCases: ValidateFieldNameAvailabilityTestContext[] =
[
{
title: 'does not throw if name is not reserved',
context: {
input: 'testName',
shouldNotThrow: true,
},
},
{
title: 'throws error with LINKS suffixes',
context: {
input: `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`,
},
},
{
title: 'throws error with CURRENCY suffixes',
context: {
input: `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`,
},
},
{
title: 'throws error with FULL_NAME suffixes',
context: {
input: `${FIELD_FULL_NAME_MOCK_NAME}FirstName`,
},
},
{
title: 'throws error with ACTOR suffixes',
context: {
input: `${FIELD_ACTOR_MOCK_NAME}Name`,
},
},
{
title: 'throws error with ADDRESS suffixes',
context: {
input: `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`,
},
},
];
describe('validateFieldNameAvailabilityOrThrow', () => {
it.each(validateFieldNameAvailabilityTestCases)(
'$title',
({ context: { input, shouldNotThrow } }) => {
if (shouldNotThrow) {
expect(() =>
validateFieldNameAvailabilityOrThrow({
name: input,
flatObjectMetadata,
flatFieldMetadataMaps,
}),
).not.toThrow();
} else {
expect(() =>
validateFieldNameAvailabilityOrThrow({
name: input,
flatObjectMetadata,
flatFieldMetadataMaps,
}),
).toThrowErrorMatchingSnapshot();
}
},
);
});
@@ -1,88 +0,0 @@
import { type EachTestingContext } from 'twenty-shared/testing';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
type ValidateMetadataNameTestContext = EachTestingContext<{
input: string;
shouldNotThrow?: true;
}>;
const validateMetadataNameTestCases: ValidateMetadataNameTestContext[] = [
{
title: 'validates when string is valid',
context: {
input: 'testName',
shouldNotThrow: true,
},
},
{
title: 'throw error when string is not in camel case',
context: {
input: 'TestName',
},
},
{
title: 'throws error when string has spaces',
context: {
input: 'name with spaces',
},
},
{
title: 'throws error when string is a reserved word',
context: {
input: 'role',
},
},
{
title: 'throws error when string starts with capital letter',
context: {
input: 'StringStartingWithCapitalLetter',
},
},
{
title: 'throws error when string has non latin characters',
context: {
input: 'בְרִבְרִ',
},
},
{
title: 'throws error when starts with digits',
context: {
input: '123string',
},
},
{
title: 'validates when string is less than 63 characters',
context: {
input: 'a'.repeat(63),
shouldNotThrow: true,
},
},
{
title: 'throws error when string is above 63 characters',
context: {
input: 'a'.repeat(64),
},
},
{
title: 'throws error when string is empty',
context: {
input: '',
},
},
];
describe('validateMetadataNameOrThrow', () => {
it.each(validateMetadataNameTestCases)(
'$title',
({ context: { input, shouldNotThrow } }) => {
if (shouldNotThrow) {
expect(() => validateMetadataNameOrThrow(input)).not.toThrow();
} else {
expect(() =>
validateMetadataNameOrThrow(input),
).toThrowErrorMatchingSnapshot();
}
},
);
});
@@ -8,7 +8,7 @@ import {
// Server-specific wrapper that converts generic errors to InvalidMetadataException
// This provides consistent error handling with proper exception codes for the server
export const computeMetadataNameFromLabel = (label: string): string => {
export const computeMetadataNameFromLabelOrThrow = (label: string): string => {
if (!isDefined(label)) {
throw new InvalidMetadataException(
'Label is required',
@@ -28,20 +28,3 @@ export const computeMetadataNameFromLabel = (label: string): string => {
throw error;
}
};
export const validateNameAndLabelAreSyncOrThrow = ({
label,
name,
}: {
label: string;
name: string;
}) => {
const computedName = computeMetadataNameFromLabel(label);
if (name !== computedName) {
throw new InvalidMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${name}`,
InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
);
}
};
@@ -7,7 +7,9 @@ import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const METADATA_NAME_VALIDATORS: FlatMetadataValidator<string>[] = [
{
@@ -1,15 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
export const isArgDefinedIfProvidedOrThrow = ({
input,
key,
value,
}: {
input: object;
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
}) => {
if (key in input && !isDefined(value)) {
throw new Error(`${key} must be defined when provided`);
}
};
@@ -1,89 +0,0 @@
import { msg } from '@lingui/core/macro';
import {
FieldMetadataType,
compositeTypeDefinitions,
} from 'twenty-shared/types';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/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 FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
const getReservedCompositeFieldNames = (
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
) => {
const reservedCompositeFieldsNames: string[] = [];
for (const fieldId of flatObjectMetadata.fieldMetadataIds) {
const field = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: fieldId,
});
if (isCompositeFieldMetadataType(field.type)) {
const base = field.name;
const compositeType = compositeTypeDefinitions.get(field.type);
compositeType?.properties.map((property) =>
reservedCompositeFieldsNames.push(
computeCompositeColumnName(base, property),
),
);
}
}
return reservedCompositeFieldsNames;
};
type ValidateFieldNameAvailabilityOrThrowArgs = {
name: string;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
};
export const validateFieldNameAvailabilityOrThrow = ({
name,
flatObjectMetadata,
flatFieldMetadataMaps,
}: ValidateFieldNameAvailabilityOrThrowArgs) => {
const reservedCompositeFieldsNames = getReservedCompositeFieldNames(
flatObjectMetadata,
flatFieldMetadataMaps,
);
for (const fieldId of flatObjectMetadata.fieldMetadataIds) {
const field = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: fieldId,
});
if (
field.name === name ||
(field.type === FieldMetadataType.RELATION && `${field.name}Id` === name)
) {
throw new InvalidMetadataException(
`Name "${name}" is not available as it is already used by another field`,
InvalidMetadataExceptionCode.NOT_AVAILABLE,
{
userFriendlyMessage: msg`This name is not available as it is already used by another field.`,
},
);
}
}
if (reservedCompositeFieldsNames.includes(name)) {
throw new InvalidMetadataException(
`Name "${name}" is not available`,
InvalidMetadataExceptionCode.RESERVED_KEYWORD,
{
userFriendlyMessage: msg`This name is not available.`,
},
);
}
};
@@ -1,92 +0,0 @@
import {
isDefined,
isLabelIdentifierFieldMetadataTypes,
} from 'twenty-shared/utils';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
type Validator = {
validator: (args: {
fieldMetadataId: string;
matchingFieldMetadata?: FieldMetadataEntity;
}) => boolean;
label: string;
};
type ValidateMetadataIdentifierFieldMetadataIdOrThrowArgs = {
fieldMetadataId: string;
fieldMetadataItems: FieldMetadataEntity[];
validators: Validator[];
};
const validatorRunner = ({
fieldMetadataId,
fieldMetadataItems,
validators,
}: ValidateMetadataIdentifierFieldMetadataIdOrThrowArgs): void => {
const matchingFieldMetadata = fieldMetadataItems.find(
(fieldMetadata) => fieldMetadata.id === fieldMetadataId,
);
validators.forEach(({ label, validator }) => {
if (validator({ fieldMetadataId, matchingFieldMetadata })) {
throw new ObjectMetadataException(
label,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
);
}
});
};
type ValidateMetadataIdentifierFieldMetadataIdsArgs = {
labelIdentifierFieldMetadataId: string | undefined;
imageIdentifierFieldMetadataId: string | undefined;
fieldMetadataItems: FieldMetadataEntity[];
};
export const validateMetadataIdentifierFieldMetadataIds = ({
imageIdentifierFieldMetadataId,
labelIdentifierFieldMetadataId,
fieldMetadataItems,
}: ValidateMetadataIdentifierFieldMetadataIdsArgs) => {
const isMatchingFieldMetadataDefined: Validator['validator'] = ({
matchingFieldMetadata,
}) => !isDefined(matchingFieldMetadata);
if (isDefined(labelIdentifierFieldMetadataId)) {
validatorRunner({
fieldMetadataId: labelIdentifierFieldMetadataId,
fieldMetadataItems,
validators: [
{
validator: isMatchingFieldMetadataDefined,
label:
'labelIdentifierFieldMetadataId validation failed: related field metadata not found',
},
{
validator: ({ matchingFieldMetadata }) =>
isDefined(matchingFieldMetadata) &&
!isLabelIdentifierFieldMetadataTypes(matchingFieldMetadata.type),
label:
'labelIdentifierFieldMetadataId validation failed: it must be a TEXT or FULL_NAME field metadata type id',
},
],
});
}
if (isDefined(imageIdentifierFieldMetadataId)) {
validatorRunner({
fieldMetadataId: imageIdentifierFieldMetadataId,
fieldMetadataItems,
validators: [
{
validator: isMatchingFieldMetadataDefined,
label:
'imageIdentifierFieldMetadataId validation failed: related field metadata not found',
},
],
});
}
};
@@ -1,16 +0,0 @@
import { t } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export const validateMetadataNameIsCamelCaseOrThrow = (name: string) => {
if (name !== camelCase(name)) {
throw new InvalidMetadataException(
t`${name} should be in camelCase`,
InvalidMetadataExceptionCode.NOT_CAMEL_CASE,
);
}
};
@@ -1,21 +0,0 @@
import { msg } from '@lingui/core/macro';
import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
name: string,
) => {
if (RESERVED_METADATA_NAME_KEYWORDS.includes(name)) {
throw new InvalidMetadataException(
`The name "${name}" is not available`,
InvalidMetadataExceptionCode.RESERVED_KEYWORD,
{
userFriendlyMessage: msg`This name is not available.`,
},
);
}
};
@@ -1,16 +0,0 @@
import { t } from '@lingui/core/macro';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
export const validateMetadataNameIsNotTooLongOrThrow = (name: string) => {
if (exceedsDatabaseIdentifierMaximumLength(name)) {
throw new InvalidMetadataException(
t`Name is too long: it exceeds the 63 characters limit.`,
InvalidMetadataExceptionCode.EXCEEDS_MAX_LENGTH,
);
}
};
@@ -1,16 +0,0 @@
import { t } from '@lingui/core/macro';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { beneathDatabaseIdentifierMinimumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
export const validateMetadataNameIsNotTooShortOrThrow = (name: string) => {
if (beneathDatabaseIdentifierMinimumLength(name)) {
throw new InvalidMetadataException(
t`Input is too short: "${name}"`,
InvalidMetadataExceptionCode.INPUT_TOO_SHORT,
);
}
};
@@ -1,17 +0,0 @@
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,23 +0,0 @@
import { t } from '@lingui/core/macro';
import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow =
(name: string) => {
if (
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
)
) {
throw new InvalidMetadataException(
t`String "${name}" is not valid: must start with lowercase letter and contain only alphanumeric letters`,
InvalidMetadataExceptionCode.INVALID_STRING,
);
}
};
@@ -1,49 +0,0 @@
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
type ValidateNoOtherObjectWithSameNameExistsOrThrowsParams = {
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
existingObjectMetadataId?: string;
objectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
};
export const doesOtherObjectWithSameNameExists = ({
objectMetadataMaps,
objectMetadataNamePlural,
objectMetadataNameSingular,
existingObjectMetadataId,
}: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams) =>
Object.values(objectMetadataMaps.byId)
.filter(isDefined)
.some(
(objectMetadata) =>
(objectMetadata.nameSingular === objectMetadataNameSingular ||
objectMetadata.namePlural === objectMetadataNamePlural ||
objectMetadata.nameSingular === objectMetadataNamePlural ||
objectMetadata.namePlural === objectMetadataNameSingular) &&
objectMetadata.id !== existingObjectMetadataId,
);
export const validatesNoOtherObjectWithSameNameExistsOrThrows = (
args: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams,
) => {
const objectAlreadyExists = doesOtherObjectWithSameNameExists(args);
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
{
userFriendlyMessage: msg`Object already exists`,
},
);
}
};