FieldMetadata ENUM CREATE UPDATE server validation and integration tests (#12121)

# Introduction

Big diff a lot of tests and snapshots ( real diff < 500+ )

close https://github.com/twentyhq/twenty/issues/12117
close https://github.com/twentyhq/twenty/issues/12133

## What has been done here
Implemented a strong integration coverage on both fieldmetadata`SELECT`
`UPDATE` and `CREATE`.
Implemented server side validation for the options `value` `label` `id`
and collision issue with also `position`

We could improve:
- Position validation
- DefaultValue validation

## Update
```ts
 PASS  test/integration/metadata/suites/field-metadata/update-one-field-metadata-select.integration-spec.ts (41.054 s)
  Field metadata select update tests group
    ✓ Update should succeed with provided option id (2565 ms)
    ✓ Update should succeed with valid default value (1469 ms)
    ✓ Update should succeed with various options id (1257 ms)
    ✓ Update should succeed without option id (1286 ms)
    ✓ Update should trim option values (1366 ms)
    ✓ Update should succeed with default value and no options (1122 ms)
    ✓ Update should fail with unknown default value and no options (1075 ms)
    ✓ Update should fail with only white spaces id (1195 ms)
    ✓ Update should fail with empty string id (1058 ms)
    ✓ Update should fail with null id (1066 ms)
    ✓ Update should fail with not a string id (1098 ms)
    ✓ Update should fail with too long id (1373 ms)
    ✓ Update should fail with only white spaces label (1034 ms)
    ✓ Update should fail with empty string label (1057 ms)
    ✓ Update should fail with null label (1100 ms)
    ✓ Update should fail with not a string label (1144 ms)
    ✓ Update should fail with too long label (1273 ms)
    ✓ Update should fail with only white spaces value (1385 ms)
    ✓ Update should fail with empty string value (1035 ms)
    ✓ Update should fail with null value (1068 ms)
    ✓ Update should fail with not a string value (1021 ms)
    ✓ Update should fail with too long value (1134 ms)
    ✓ Update should fail with invalid option id (1137 ms)
    ✓ Update should fail with empty options (1238 ms)
    ✓ Update should fail with invalid option value format (1104 ms)
    ✓ Update should fail with comma in option label (1004 ms)
    ✓ Update should fail with duplicated option values (1015 ms)
    ✓ Update should fail with duplicated option ids (1079 ms)
    ✓ Update should fail with duplicated option positions (1266 ms)
    ✓ Update should fail with duplicated trimmed option values (1220 ms)
    ✓ Update should fail with undefined option label (1029 ms)
    ✓ Update should fail with an invalid default value (1142 ms)
    ✓ Update should fail with an unknown default value (1081 ms)
    ✓ Update should fail with undefined option value (1086 ms)

Test Suites: 1 passed, 1 total
Tests:       34 passed, 34 total
Snapshots:   28 passed, 28 total
Time:        41.079 s
```


## Create
```ts
 PASS  test/integration/metadata/suites/field-metadata/create-one-field-metadata-select.integration-spec.ts (38.292 s)
  Field metadata select creation tests group
    ✓ Create should succeed with provided option id (2096 ms)
    ✓ Create should succeed with valid default value (1316 ms)
    ✓ Create should succeed with various options id (1113 ms)
    ✓ Create should succeed without option id (1378 ms)
    ✓ Create should trim option values (1296 ms)
    ✓ Create should fail with only white spaces id (1000 ms)
    ✓ Create should fail with empty string id (1325 ms)
    ✓ Create should fail with null id (1060 ms)
    ✓ Create should fail with not a string id (1142 ms)
    ✓ Create should fail with too long id (1321 ms)
    ✓ Create should fail with only white spaces label (999 ms)
    ✓ Create should fail with empty string label (1163 ms)
    ✓ Create should fail with null label (1198 ms)
    ✓ Create should fail with not a string label (1678 ms)
    ✓ Create should fail with too long label (1527 ms)
    ✓ Create should fail with only white spaces value (1200 ms)
    ✓ Create should fail with empty string value (1102 ms)
    ✓ Create should fail with null value (1037 ms)
    ✓ Create should fail with not a string value (1462 ms)
    ✓ Create should fail with too long value (896 ms)
    ✓ Create should fail with invalid option id (997 ms)
    ✓ Create should fail with empty options (1058 ms)
    ✓ Create should fail with invalid option value format (1190 ms)
    ✓ Create should fail with comma in option label (1142 ms)
    ✓ Create should fail with duplicated option values (872 ms)
    ✓ Create should fail with duplicated option ids (860 ms)
    ✓ Create should fail with duplicated option positions (1002 ms)
    ✓ Create should fail with duplicated trimmed option values (1336 ms)
    ✓ Create should fail with undefined option label (754 ms)
    ✓ Create should fail with an invalid default value (696 ms)
    ✓ Create should fail with an unknown default value (678 ms)
    ✓ Create should fail with undefined option value (699 ms)
    ✓ Create should fail with null options (720 ms)
    ✓ Create should fail with undefined options (686 ms)

Test Suites: 1 passed, 1 total
Tests:       34 passed, 34 total
Snapshots:   29 passed, 29 total
Time:        38.314 s
```

## Conclusion
As always any suggestions are welcomed ! Please let me know


## Discussion about validation governance
### Front
Front side will be dealing with zod validations schema that he will
handle and maintain by himself

### Back validation instances
- Validation hold through DTO declarations ( run by yoga through the
resolvers )
- Server programmatic validation and exceptions handling ( run through
the services )

For this refactor/fix we decided to stick to the current implementation
only touching the `Server programmatic validation and exceptions
handling` we will handle validation centralization when we will onboard
the `nestjs-query` deprecation/integration refactor.

### Vision
In the best of the world we could think of an intermediary model that
will handle and take responsibility of the validation decorators that
would be run programmatically through the service, Yoga would still
consume it ? then we would need to have enough grain in the service to
know the input has already validated

## Notes
Introduced zod back side in order to handle very atomic and primitive
validation
This commit is contained in:
Paul Rastoin
2025-05-22 17:58:59 +02:00
committed by GitHub
parent 7cc0a7ae72
commit 45c89a46d6
21 changed files with 1959 additions and 115 deletions

View File

@ -17,6 +17,7 @@ import { FieldMetadataResolver } from 'src/engine/metadata-modules/field-metadat
import { BeforeUpdateOneField } from 'src/engine/metadata-modules/field-metadata/hooks/before-update-one-field.hook';
import { FieldMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/field-metadata/interceptors/field-metadata-graphql-api-exception.interceptor';
import { FieldMetadataRelationService } from 'src/engine/metadata-modules/field-metadata/relation/field-metadata-relation.service';
import { FieldMetadataEnumValidationService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service';
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.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';
@ -60,6 +61,7 @@ import { UpdateFieldInput } from './dtos/update-field.input';
FieldMetadataService,
FieldMetadataRelatedRecordsService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
],
resolvers: [
{
@ -95,6 +97,8 @@ import { UpdateFieldInput } from './dtos/update-field.input';
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataValidationService,
FieldMetadataEnumValidationService,
FieldMetadataResolver,
BeforeUpdateOneField,
],
@ -102,6 +106,8 @@ import { UpdateFieldInput } from './dtos/update-field.input';
FieldMetadataService,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataEnumValidationService,
FieldMetadataValidationService,
],
})
export class FieldMetadataModule {}

View File

@ -12,15 +12,17 @@ import { v4 as uuidV4, v4 } from 'uuid';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { settings } from 'src/engine/constants/settings';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input';
import { FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import {
RelationDefinitionDTO,
RelationDefinitionType,
@ -30,6 +32,7 @@ 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 { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service';
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
import { checkCanDeactivateFieldOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/check-can-deactivate-field-or-throw';
@ -47,7 +50,6 @@ import {
RelationMetadataType,
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { validateNameAndLabelAreSyncOrThrow } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
@ -66,13 +68,21 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.
import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target-table.util';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
import { ViewService } from 'src/modules/view/services/view.service';
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties';
import { FieldMetadataValidationService } from './field-metadata-validation.service';
import { FieldMetadataEntity } from './field-metadata.entity';
import { generateDefaultValue } from './utils/generate-default-value';
import { generateRatingOptions } from './utils/generate-rating-optionts.util';
import { isEnumFieldMetadataType } from './utils/is-enum-field-metadata-type.util';
type ValidateFieldMetadataArgs<T extends UpdateFieldInput | CreateFieldInput> =
{
fieldMetadataType: FieldMetadataType;
fieldMetadataInput: T;
objectMetadata: ObjectMetadataEntity;
existingFieldMetadata?: FieldMetadataEntity;
};
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntity> {
@ -88,8 +98,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly workspaceMigrationFactory: WorkspaceMigrationFactory,
private readonly workspaceMigrationService: WorkspaceMigrationService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
private readonly fieldMetadataEnumValidationService: FieldMetadataEnumValidationService,
private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
@ -104,7 +113,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
): Promise<FieldMetadataEntity> {
const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]);
if (!createdFieldMetadata) {
if (!isDefined(createdFieldMetadata)) {
throw new FieldMetadataException(
'Failed to create field metadata',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
@ -136,7 +145,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
},
});
if (!existingFieldMetadata) {
if (!isDefined(existingFieldMetadata)) {
throw new FieldMetadataException(
'Field does not exist',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
@ -152,14 +161,14 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
order: {},
});
if (!objectMetadata) {
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
if (!objectMetadata.labelIdentifierFieldMetadataId) {
if (!isDefined(objectMetadata.labelIdentifierFieldMetadataId)) {
throw new FieldMetadataException(
'Label identifier field metadata id does not exist',
FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND,
@ -190,17 +199,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
});
}
if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) {
if (!option.id) {
throw new FieldMetadataException(
'Option id is required',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
}
const updatableFieldInput =
existingFieldMetadata.isCustom === false
? this.buildUpdatableStandardFieldInput(
@ -209,19 +207,26 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
)
: fieldMetadataInput;
const optionsForUpdate = isDefined(fieldMetadataInput.options)
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValueForUpdate =
updatableFieldInput.defaultValue !== undefined
? updatableFieldInput.defaultValue
: existingFieldMetadata.defaultValue;
const fieldMetadataForUpdate = {
...updatableFieldInput,
defaultValue:
updatableFieldInput.defaultValue !== undefined
? updatableFieldInput.defaultValue
: existingFieldMetadata.defaultValue,
defaultValue: defaultValueForUpdate,
...optionsForUpdate,
};
await this.validateFieldMetadata<UpdateFieldInput>(
existingFieldMetadata.type,
fieldMetadataForUpdate,
await this.validateFieldMetadata({
fieldMetadataType: existingFieldMetadata.type,
existingFieldMetadata,
fieldMetadataInput: fieldMetadataForUpdate,
objectMetadata,
);
});
const isLabelSyncedWithName =
fieldMetadataForUpdate.isLabelSyncedWithName ??
@ -241,7 +246,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
where: { id },
});
if (!updatedFieldMetadata) {
if (!isDefined(updatedFieldMetadata)) {
throw new FieldMetadataException(
'Field does not exist',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
@ -605,11 +610,12 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private async validateFieldMetadata<
T extends UpdateFieldInput | CreateFieldInput,
>(
fieldMetadataType: FieldMetadataType,
fieldMetadataInput: T,
objectMetadata: ObjectMetadataEntity,
): Promise<T> {
>({
fieldMetadataInput,
fieldMetadataType,
objectMetadata,
existingFieldMetadata,
}: ValidateFieldMetadataArgs<T>): Promise<T> {
if (fieldMetadataInput.name) {
try {
validateMetadataNameOrThrow(fieldMetadataInput.name);
@ -650,23 +656,13 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
}
if (fieldMetadataInput.options) {
for (const option of fieldMetadataInput.options) {
if (exceedsDatabaseIdentifierMaximumLength(option.value)) {
throw new FieldMetadataException(
`Option value "${option.value}" exceeds 63 characters`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (isDefined(fieldMetadataInput.defaultValue)) {
await this.fieldMetadataValidationService.validateDefaultValueOrThrow({
fieldType: fieldMetadataType,
options: fieldMetadataInput.options,
defaultValue: fieldMetadataInput.defaultValue ?? null,
});
}
}
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
{
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
},
);
if (fieldMetadataInput.settings) {
await this.fieldMetadataValidationService.validateSettingsOrThrow({
@ -716,7 +712,28 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
return translatedMessage;
}
private prepareCustomFieldMetadataOptions(
options: FieldMetadataDefaultOption[] | FieldMetadataComplexOption[],
): undefined | Pick<FieldMetadataEntity, 'options'> {
return {
options: options.map((option) => ({
id: uuidV4(),
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
option,
['label', 'value', 'id'],
),
})),
};
}
private prepareCustomFieldMetadata(fieldMetadataInput: CreateFieldInput) {
const options = fieldMetadataInput.options
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
: undefined;
const defaultValue =
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type);
return {
id: v4(),
createdAt: new Date(),
@ -727,15 +744,8 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput.isNullable,
fieldMetadataInput.isRemoteCreation,
),
defaultValue:
fieldMetadataInput.defaultValue ??
generateDefaultValue(fieldMetadataInput.type),
options: fieldMetadataInput.options
? fieldMetadataInput.options.map((option) => ({
...option,
id: uuidV4(),
}))
: undefined,
defaultValue,
...options,
isActive: true,
isCustom: true,
};
@ -766,18 +776,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
assertMutationNotOnRemoteObject(objectMetadata);
}
if (isEnumFieldMetadataType(fieldMetadataInput.type)) {
if (
!fieldMetadataInput.options &&
fieldMetadataInput.type !== FieldMetadataType.RATING
) {
throw new FieldMetadataException(
'Options are required for enum fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
if (fieldMetadataInput.type === FieldMetadataType.RATING) {
fieldMetadataInput.options = generateRatingOptions();
}
@ -785,11 +783,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
const fieldMetadataForCreate =
this.prepareCustomFieldMetadata(fieldMetadataInput);
await this.validateFieldMetadata<CreateFieldInput>(
fieldMetadataForCreate.type,
fieldMetadataForCreate,
await this.validateFieldMetadata({
fieldMetadataType: fieldMetadataForCreate.type,
fieldMetadataInput: fieldMetadataForCreate,
objectMetadata,
);
});
if (fieldMetadataForCreate.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow(
@ -862,7 +860,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
for (const objectMetadataId of objectMetadataIds) {
const objectMetadata = objectMetadataMap[objectMetadataId];
if (!objectMetadata) {
if (!isDefined(objectMetadata)) {
throw new FieldMetadataException(
'Object metadata does not exist',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
@ -887,7 +885,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
fieldMetadataInput.isRemoteCreation ?? false,
);
if (migrationAction) {
if (isDefined(migrationAction)) {
migrationActions.push(migrationAction);
}
}

View File

@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input';
import { FieldMetadataValidationService } from 'src/engine/metadata-modules/field-metadata/field-metadata-validation.service';
import { 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 { isEnumFieldMetadataType } 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: string };
type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput;
type ValidateEnumFieldMetadataArgs = {
existingFieldMetadata?: FieldMetadataEntity;
fieldMetadataInput: FieldMetadataUpdateCreateInput;
fieldMetadataType: FieldMetadataType;
};
@Injectable()
export class FieldMetadataEnumValidationService {
constructor(
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
) {}
private validatorRunner<T>(
elementToValidate: T,
{ message, validator }: Validator<T>,
) {
const shouldThrow = validator(elementToValidate);
if (shouldThrow) {
throw new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
}
private validateMetadataOptionId(sanitizedId?: string) {
const validators: Validator<string>[] = [
{
validator: (id) => !isDefined(id),
message: 'Option id is required',
},
{
validator: (id) => !z.string().uuid().safeParse(id).success,
message: 'Option id is invalid',
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedId, validator),
);
}
private validateMetadataOptionLabel(sanitizedLabel: string) {
const validators: Validator<string>[] = [
{
validator: (label) => !isDefined(label),
message: 'Option label is required',
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: `Option label "${sanitizedLabel}" exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: `Option label "${sanitizedLabel}" is beneath 1 character`,
},
{
validator: (label) => label.includes(','),
message: 'Label must not contain a comma',
},
{
validator: (label) => !isNonEmptyString(label) || label === ' ',
message: '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: 'Option value is required',
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: `Option value "${sanitizedValue}" exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: `Option value "${sanitizedValue}" is beneath 1 character`,
},
{
validator: (value) => !isSnakeCaseString(value),
message: `Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
},
];
validators.forEach((validator) =>
this.validatorRunner(sanitizedValue, validator),
);
}
private validateDuplicates(options: FieldMetadataOptions) {
const seenOptionIds = new Set<FieldMetadataOptions[number]['id']>();
const seenOptionValues = new Set<FieldMetadataOptions[number]['value']>();
const seenOptionPositions = new Set<
FieldMetadataOptions[number]['position']
>();
for (const option of options) {
if (seenOptionIds.has(option.id)) {
throw new FieldMetadataException(
`Duplicated option id "${option.id}"`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
if (seenOptionValues.has(option.value)) {
throw new FieldMetadataException(
`Duplicated option value "${option.value}"`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
if (seenOptionPositions.has(option.position)) {
throw new FieldMetadataException(
`Duplicated option position "${option.position}"`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
);
}
seenOptionIds.add(option.id);
seenOptionValues.add(option.value);
seenOptionPositions.add(option.position);
}
}
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);
}
async validateEnumFieldMetadataInput({
fieldMetadataInput,
fieldMetadataType,
existingFieldMetadata,
}: ValidateEnumFieldMetadataArgs) {
if (!isEnumFieldMetadataType(fieldMetadataType)) {
return;
}
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,
);
}
await this.fieldMetadataValidationService.validateDefaultValueOrThrow({
fieldType: fieldMetadataType,
options,
defaultValue: fieldMetadataInput.defaultValue,
});
}
}
}

View File

@ -1,7 +1,7 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
ValidationOptions,
registerDecorator,
} from 'class-validator';
const graphQLEnumNameRegex = /^[_A-Za-z][_0-9A-Za-z]*$/;
@ -14,8 +14,7 @@ export function IsValidGraphQLEnumName(validationOptions?: ValidationOptions) {
propertyName: propertyName,
options: validationOptions,
validator: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validate(value: any) {
validate(value: unknown) {
return typeof value === 'string' && graphQLEnumNameRegex.test(value);
},
defaultMessage(args: ValidationArguments) {

View File

@ -1,9 +1,8 @@
export const PETS_DATA_SEEDS = [
{
name: 'Toby',
species: 'dog',
traits: ['curious', 'friendly'],
species: 'DOG',
traits: ['CURIOUS', 'FRIENDLY'],
comments: 'Needs to have people around.',
age: 3,
location: {

View File

@ -13,12 +13,12 @@ export const PETS_METADATA_SEEDS: ObjectMetadataSeed = {
label: 'Species',
name: 'species',
options: [
{ label: 'Dog', value: 'dog', position: 0, color: 'blue' },
{ label: 'Cat', value: 'cat', position: 1, color: 'red' },
{ label: 'Bird', value: 'bird', position: 2, color: 'green' },
{ label: 'Fish', value: 'fish', position: 3, color: 'yellow' },
{ label: 'Rabbit', value: 'rabbit', position: 4, color: 'purple' },
{ label: 'Hamster', value: 'hamster', position: 5, color: 'orange' },
{ label: 'Dog', value: 'DOG', position: 0, color: 'blue' },
{ label: 'Cat', value: 'CAT', position: 1, color: 'red' },
{ label: 'Bird', value: 'BIRD', position: 2, color: 'green' },
{ label: 'Fish', value: 'FISH', position: 3, color: 'yellow' },
{ label: 'Rabbit', value: 'RABBIT', position: 4, color: 'purple' },
{ label: 'Hamster', value: 'HAMSTER', position: 5, color: 'orange' },
],
},
{
@ -26,17 +26,17 @@ export const PETS_METADATA_SEEDS: ObjectMetadataSeed = {
label: 'Traits',
name: 'traits',
options: [
{ label: 'Playful', value: 'playful', position: 0, color: 'blue' },
{ label: 'Friendly', value: 'friendly', position: 1, color: 'red' },
{ label: 'Playful', value: 'PLAYFUL', position: 0, color: 'blue' },
{ label: 'Friendly', value: 'FRIENDLY', position: 1, color: 'red' },
{
label: 'Protective',
value: 'protective',
value: 'PROTECTIVE',
position: 2,
color: 'green',
},
{ label: 'Shy', value: 'shy', position: 3, color: 'yellow' },
{ label: 'Brave', value: 'brave', position: 4, color: 'purple' },
{ label: 'Curious', value: 'curious', position: 5, color: 'orange' },
{ label: 'Shy', value: 'SHY', position: 3, color: 'yellow' },
{ label: 'Brave', value: 'BRAVE', position: 4, color: 'purple' },
{ label: 'Curious', value: 'CURIOUS', position: 5, color: 'orange' },
],
},
{