Fix: multi-select default values validation (#12271)
https://github.com/user-attachments/assets/3bea63cc-b098-4252-8787-fc6263f01e8d Closes #12277 --------- Co-authored-by: prastoin <paul@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@ -11,8 +11,6 @@ import {
|
||||
} from 'class-validator';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface';
|
||||
import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
|
||||
|
||||
import {
|
||||
@ -91,48 +89,4 @@ export class FieldMetadataValidationService<
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async validateDefaultValueOrThrow({
|
||||
fieldType,
|
||||
defaultValue,
|
||||
options,
|
||||
}: {
|
||||
fieldType: FieldMetadataType;
|
||||
defaultValue: FieldMetadataDefaultValue<T>;
|
||||
options: FieldMetadataOptions<T>;
|
||||
}) {
|
||||
if (
|
||||
fieldType === FieldMetadataType.SELECT ||
|
||||
fieldType === FieldMetadataType.MULTI_SELECT
|
||||
) {
|
||||
this.validateEnumDefaultValue(options, defaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
private validateEnumDefaultValue(
|
||||
options: FieldMetadataOptions<T>,
|
||||
defaultValue: FieldMetadataDefaultValue<T>,
|
||||
) {
|
||||
if (typeof defaultValue === 'string') {
|
||||
const formattedDefaultValue = defaultValue.replace(
|
||||
/^['"](.*)['"]$/,
|
||||
'$1',
|
||||
);
|
||||
|
||||
const enumOptions = options.map((option) => option.value);
|
||||
|
||||
if (
|
||||
enumOptions &&
|
||||
(enumOptions.includes(formattedDefaultValue) ||
|
||||
// @ts-expect-error legacy noImplicitAny
|
||||
enumOptions.some((option) => option.to === formattedDefaultValue))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new FieldMetadataException(
|
||||
`Default value for existing options is invalid: ${defaultValue}`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +68,7 @@ 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 { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
|
||||
import { FieldMetadataValidationService } from './field-metadata-validation.service';
|
||||
@ -662,13 +663,15 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
}
|
||||
}
|
||||
|
||||
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
|
||||
{
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
existingFieldMetadata,
|
||||
},
|
||||
);
|
||||
if (isEnumFieldMetadataType(fieldMetadataType)) {
|
||||
await this.fieldMetadataEnumValidationService.validateEnumFieldMetadataInput(
|
||||
{
|
||||
fieldMetadataInput,
|
||||
fieldMetadataType,
|
||||
existingFieldMetadata,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMetadataInput.settings) {
|
||||
await this.fieldMetadataValidationService.validateSettingsOrThrow({
|
||||
@ -732,7 +735,9 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
};
|
||||
}
|
||||
|
||||
private prepareCustomFieldMetadata(fieldMetadataInput: CreateFieldInput) {
|
||||
private prepareCustomFieldMetadataForCreation(
|
||||
fieldMetadataInput: CreateFieldInput,
|
||||
) {
|
||||
const options = fieldMetadataInput.options
|
||||
? this.prepareCustomFieldMetadataOptions(fieldMetadataInput.options)
|
||||
: undefined;
|
||||
@ -787,7 +792,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
}
|
||||
|
||||
const fieldMetadataForCreate =
|
||||
this.prepareCustomFieldMetadata(fieldMetadataInput);
|
||||
this.prepareCustomFieldMetadataForCreation(fieldMetadataInput);
|
||||
|
||||
await this.validateFieldMetadata({
|
||||
fieldMetadataType: fieldMetadataForCreate.type,
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
FieldMetadataDefaultValueRawJson,
|
||||
FieldMetadataDefaultValueRichText,
|
||||
FieldMetadataDefaultValueString,
|
||||
FieldMetadataDefaultValueStringArray,
|
||||
FieldMetadataDefaultValueUuidFunction,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
|
||||
|
||||
@ -46,7 +47,7 @@ type FieldMetadataDefaultValueMapping = {
|
||||
[FieldMetadataType.ADDRESS]: FieldMetadataDefaultValueAddress;
|
||||
[FieldMetadataType.RATING]: FieldMetadataDefaultValueString;
|
||||
[FieldMetadataType.SELECT]: FieldMetadataDefaultValueString;
|
||||
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueString;
|
||||
[FieldMetadataType.MULTI_SELECT]: FieldMetadataDefaultValueStringArray;
|
||||
[FieldMetadataType.RAW_JSON]: FieldMetadataDefaultValueRawJson;
|
||||
[FieldMetadataType.RICH_TEXT]: FieldMetadataDefaultValueRichText;
|
||||
[FieldMetadataType.ACTOR]: FieldMetadataDefaultActor;
|
||||
@ -60,13 +61,21 @@ export type FieldMetadataFunctionDefaultValue = ExtractValueType<
|
||||
FieldMetadataDefaultValueUuidFunction | FieldMetadataDefaultValueNowFunction
|
||||
>;
|
||||
|
||||
export type FieldMetadataDefaultValueForType<
|
||||
T extends keyof FieldMetadataDefaultValueMapping,
|
||||
> = ExtractValueType<FieldMetadataDefaultValueMapping[T]> | null;
|
||||
|
||||
export type FieldMetadataDefaultValueForAnyType = ExtractValueType<
|
||||
UnionOfValues<FieldMetadataDefaultValueMapping>
|
||||
> | null;
|
||||
|
||||
export type FieldMetadataDefaultValue<
|
||||
T extends FieldMetadataType = FieldMetadataType,
|
||||
> =
|
||||
IsExactly<T, FieldMetadataType> extends true
|
||||
? ExtractValueType<UnionOfValues<FieldMetadataDefaultValueMapping>> | null
|
||||
? FieldMetadataDefaultValueForAnyType
|
||||
: T extends keyof FieldMetadataDefaultValueMapping
|
||||
? ExtractValueType<FieldMetadataDefaultValueMapping[T]> | null
|
||||
? FieldMetadataDefaultValueForType<T>
|
||||
: never;
|
||||
|
||||
type FieldMetadataDefaultValueExtractedTypes = {
|
||||
|
||||
@ -2,24 +2,28 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { assertUnreachable, 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 {
|
||||
FieldMetadataComplexOption,
|
||||
FieldMetadataDefaultOption,
|
||||
} from 'src/engine/metadata-modules/field-metadata/dtos/options.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 { 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 { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
|
||||
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
|
||||
|
||||
type Validator<T> = { validator: (str: T) => boolean; message: string };
|
||||
@ -29,14 +33,14 @@ type FieldMetadataUpdateCreateInput = CreateFieldInput | UpdateFieldInput;
|
||||
type ValidateEnumFieldMetadataArgs = {
|
||||
existingFieldMetadata?: FieldMetadataEntity;
|
||||
fieldMetadataInput: FieldMetadataUpdateCreateInput;
|
||||
fieldMetadataType: FieldMetadataType;
|
||||
fieldMetadataType: EnumFieldMetadataUnionType;
|
||||
};
|
||||
|
||||
const QUOTED_STRING_REGEX = /^['"](.*)['"]$/;
|
||||
|
||||
@Injectable()
|
||||
export class FieldMetadataEnumValidationService {
|
||||
constructor(
|
||||
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
|
||||
) {}
|
||||
constructor() {}
|
||||
|
||||
private validatorRunner<T>(
|
||||
elementToValidate: T,
|
||||
@ -124,38 +128,22 @@ export class FieldMetadataEnumValidationService {
|
||||
}
|
||||
|
||||
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']
|
||||
>();
|
||||
const fieldsToCheckForDuplicates = [
|
||||
'position',
|
||||
'id',
|
||||
'value',
|
||||
] as const satisfies (keyof FieldMetadataOptions[number])[];
|
||||
const duplicatedValidators = fieldsToCheckForDuplicates.map<
|
||||
Validator<FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]>
|
||||
>((field) => ({
|
||||
message: `Duplicated option ${field}`,
|
||||
validator: () =>
|
||||
new Set(options.map((option) => option[field])).size !== options.length,
|
||||
}));
|
||||
|
||||
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);
|
||||
}
|
||||
duplicatedValidators.forEach((validator) =>
|
||||
this.validatorRunner(options, validator),
|
||||
);
|
||||
}
|
||||
|
||||
private validateFieldMetadataInputOptions(
|
||||
@ -179,15 +167,97 @@ export class FieldMetadataEnumValidationService {
|
||||
this.validateDuplicates(options);
|
||||
}
|
||||
|
||||
private validateSelectDefaultValue(
|
||||
options: FieldMetadataOptions,
|
||||
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: 'Default value should be as quoted string',
|
||||
},
|
||||
{
|
||||
validator: (value: string) =>
|
||||
!options.some(
|
||||
(option) =>
|
||||
option.value === value.replace(QUOTED_STRING_REGEX, '$1'),
|
||||
),
|
||||
message: `Default value "${defaultValue}" must be one of the option values`,
|
||||
},
|
||||
];
|
||||
|
||||
validators.forEach((validator) =>
|
||||
this.validatorRunner(defaultValue, validator),
|
||||
);
|
||||
}
|
||||
|
||||
private validateMultiSelectDefaultValue(
|
||||
options: FieldMetadataOptions,
|
||||
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: 'If defined default value must contain at least one value',
|
||||
},
|
||||
{
|
||||
validator: (values) => new Set(values).size !== values.length,
|
||||
message: '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,
|
||||
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) {
|
||||
if (!isEnumFieldMetadataType(fieldMetadataType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isUpdate = isDefined(existingFieldMetadata);
|
||||
const shouldSkipFieldMetadataInputOptionsValidation =
|
||||
isUpdate && fieldMetadataInput.options === undefined;
|
||||
@ -207,11 +277,11 @@ export class FieldMetadataEnumValidationService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.fieldMetadataValidationService.validateDefaultValueOrThrow({
|
||||
fieldType: fieldMetadataType,
|
||||
this.validateFieldMetadataDefaultValue(
|
||||
fieldMetadataType,
|
||||
options,
|
||||
defaultValue: fieldMetadataInput.defaultValue,
|
||||
});
|
||||
fieldMetadataInput.defaultValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { isDefined } from 'class-validator';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
export const fieldMetadataEnumTypes = [
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.RATING,
|
||||
] as const;
|
||||
|
||||
export type EnumFieldMetadataUnionType =
|
||||
| FieldMetadataType.RATING
|
||||
| FieldMetadataType.SELECT
|
||||
| FieldMetadataType.MULTI_SELECT;
|
||||
(typeof fieldMetadataEnumTypes)[number];
|
||||
|
||||
export const isEnumFieldMetadataType = (
|
||||
type: FieldMetadataType,
|
||||
): type is EnumFieldMetadataUnionType => {
|
||||
return (
|
||||
type === FieldMetadataType.RATING ||
|
||||
type === FieldMetadataType.SELECT ||
|
||||
type === FieldMetadataType.MULTI_SELECT
|
||||
);
|
||||
};
|
||||
): type is EnumFieldMetadataUnionType =>
|
||||
isDefined(fieldMetadataEnumTypes.find((el) => type === el));
|
||||
|
||||
@ -85,7 +85,7 @@ export const validateDefaultValueForType = (
|
||||
}
|
||||
|
||||
const validationResults = validators.map((validator) => {
|
||||
const conputedDefaultValue = isCompositeFieldMetadataType(type)
|
||||
const computedDefaultValue = isCompositeFieldMetadataType(type)
|
||||
? defaultValue
|
||||
: { value: defaultValue };
|
||||
|
||||
@ -93,7 +93,7 @@ export const validateDefaultValueForType = (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
any,
|
||||
FieldMetadataClassValidation
|
||||
>(validator, conputedDefaultValue as FieldMetadataClassValidation);
|
||||
>(validator, computedDefaultValue as FieldMetadataClassValidation);
|
||||
|
||||
const errors = validateSync(defaultValueInstance, {
|
||||
whitelist: true,
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
import {
|
||||
WorkspaceHealthIssue,
|
||||
WorkspaceHealthIssueType,
|
||||
@ -17,10 +17,7 @@ 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 {
|
||||
EnumFieldMetadataUnionType,
|
||||
isEnumFieldMetadataType,
|
||||
} from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
|
||||
import { serializeDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-default-value';
|
||||
import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util';
|
||||
import { validateOptionsForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-options-for-type.util';
|
||||
@ -291,15 +288,34 @@ export class FieldMetadataHealthService {
|
||||
const enumValues = fieldMetadata.options?.map((option) =>
|
||||
serializeDefaultValue(`'${option.value}'`),
|
||||
);
|
||||
const metadataDefaultValue =
|
||||
fieldMetadata.defaultValue as FieldMetadataDefaultValue<EnumFieldMetadataUnionType>;
|
||||
const metadataDefaultValue = fieldMetadata.defaultValue;
|
||||
|
||||
if (metadataDefaultValue && !enumValues.includes(metadataDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value is not in the enum values "${metadataDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
if (isDefined(metadataDefaultValue)) {
|
||||
if (fieldMetadata.type === FieldMetadataType.MULTI_SELECT) {
|
||||
if (!Array.isArray(metadataDefaultValue)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value for multi-select must be an array, got "${metadataDefaultValue}"`,
|
||||
});
|
||||
} else {
|
||||
metadataDefaultValue.forEach((value) => {
|
||||
if (!enumValues.includes(value)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value "${value}" is not in the enum values "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (enumValues.includes(metadataDefaultValue as string)) {
|
||||
issues.push({
|
||||
type: WorkspaceHealthIssueType.COLUMN_DEFAULT_VALUE_NOT_VALID,
|
||||
fieldMetadata,
|
||||
message: `Column default value is not in the enum values "${metadataDefaultValue}" NOT IN "${enumValues}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user