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:
+4
-32
@@ -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 {}
|
||||
|
||||
-22
@@ -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[];
|
||||
}
|
||||
-304
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-172
@@ -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;
|
||||
}
|
||||
}
|
||||
-267
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
-254
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
-11
@@ -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"`;
|
||||
-38
@@ -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();
|
||||
});
|
||||
});
|
||||
-106
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
-85
@@ -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);
|
||||
});
|
||||
});
|
||||
-12
@@ -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),
|
||||
);
|
||||
-20
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-42
@@ -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;
|
||||
};
|
||||
-38
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-10
@@ -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;
|
||||
};
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
export const isSelectFieldMetadataType = (
|
||||
type: FieldMetadataType,
|
||||
): type is FieldMetadataType.SELECT => {
|
||||
return type === FieldMetadataType.SELECT;
|
||||
};
|
||||
-15
@@ -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,
|
||||
);
|
||||
};
|
||||
-23
@@ -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',
|
||||
]),
|
||||
})),
|
||||
};
|
||||
};
|
||||
-42
@@ -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,
|
||||
};
|
||||
};
|
||||
+22
-2
@@ -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,
|
||||
|
||||
-79
@@ -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';
|
||||
}
|
||||
}
|
||||
-62
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-2
@@ -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,
|
||||
|
||||
-19
@@ -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"`;
|
||||
-57
@@ -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();
|
||||
});
|
||||
});
|
||||
-15
@@ -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 };
|
||||
};
|
||||
-95
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-11
@@ -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"`;
|
||||
-17
@@ -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"`;
|
||||
+3
-3
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
-180
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
-88
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
+1
-18
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
+3
-1
@@ -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>[] = [
|
||||
{
|
||||
|
||||
-15
@@ -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`);
|
||||
}
|
||||
};
|
||||
-89
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
-92
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
-16
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-21
@@ -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.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
-16
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-16
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-17
@@ -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));
|
||||
};
|
||||
-23
@@ -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,
|
||||
);
|
||||
}
|
||||
};
|
||||
-49
@@ -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`,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user