FlatFieldMetadataType hashmap arch and enum validation (#13502)

# Introduction
- FlatFieldMetadataType validators hashmap
- Do not fail fast on validation but aggregate errors
- Implemented `enum` validation
- Plugged the new v2 dynamic call in the field metadata service v2

## What's next:
- Implem integration tests to make things run 🙃 
- migrate existing settings valdiation
- Finish the create object metadata service
- Handle update input transpilation and validation

## Open question
Should we implement, not covered validation ?, adding strictness now or
never.
This will be required by the import anw

## Discovered issue with cache
Currently the cache is not accurately typed, `fieldsById` map are not
storing any relations.
Which means the current transpilation tools are hitting undefined at
runtime
In the best of the world we will refactor the cache to be storing
`FlatObjectMetadata` and `FlatFieldMetadata` so we don't even have to
transpile them for validation and so on
But it would require to refactor the loaders that returns the cache to
the front on hit as FieldMetadataEntity, so we might land on a lighter
solution to rather add a new `getExistingFlatCache` that handles the
transpilation itself
About to do that in an other PR to be discussed with Coco
This commit is contained in:
Paul Rastoin
2025-07-31 14:54:39 +02:00
committed by GitHub
parent 7e7c64ac35
commit 14537d74b4
30 changed files with 841 additions and 220 deletions
@@ -1,4 +1,4 @@
export class AggregateError extends Error {
export class MultipleMetadataValidationErrors extends Error {
constructor(
public readonly errors: Error[],
message = 'Multiple errors occurred',
@@ -21,8 +21,10 @@ import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/f
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.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 { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
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 { FlatFieldMetadataModule } from 'src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -31,6 +33,8 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
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 { WorkspaceMigrationBuilderV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.module';
import { WorkspaceMigrationRunnerV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.module';
import { ViewModule } from 'src/modules/view/view.module';
import { FieldMetadataEntity } from './field-metadata.entity';
@@ -59,10 +63,14 @@ import { FieldMetadataService } from './services/field-metadata.service';
ViewModule,
PermissionsModule,
WorkspaceMetadataCacheModule,
WorkspaceMigrationBuilderV2Module,
WorkspaceMigrationRunnerV2Module,
FlatFieldMetadataModule,
],
services: [
IsFieldMetadataDefaultValue,
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
FieldMetadataRelationService,
@@ -101,6 +109,7 @@ import { FieldMetadataService } from './services/field-metadata.service';
IsFieldMetadataDefaultValue,
IsFieldMetadataOptions,
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelationService,
FieldMetadataRelatedRecordsService,
FieldMetadataMorphRelationService,
@@ -111,6 +120,7 @@ import { FieldMetadataService } from './services/field-metadata.service';
],
exports: [
FieldMetadataService,
FieldMetadataServiceV2,
FieldMetadataRelationService,
FieldMetadataMorphRelationService,
FieldMetadataRelatedRecordsService,
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -24,7 +24,6 @@ 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> = {
@@ -5,9 +5,13 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AggregateError } from 'src/engine/core-modules/error/aggregate-error';
import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
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 { FlatFieldMetadataValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { fromCreateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -30,6 +34,21 @@ export class FieldMetadataServiceV2 extends TypeOrmQueryService<FieldMetadataEnt
super(fieldMetadataRepository);
}
override async createOne(
fieldMetadataInput: CreateFieldInput,
): Promise<FieldMetadataEntity> {
const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]);
if (!isDefined(createdFieldMetadata)) {
throw new FieldMetadataException(
'Failed to create field metadata',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return createdFieldMetadata;
}
async createMany(
fieldMetadataInputs: CreateFieldInput[],
): Promise<FieldMetadataEntity[]> {
@@ -72,11 +91,9 @@ export class FieldMetadataServiceV2 extends TypeOrmQueryService<FieldMetadataEnt
).filter(isDefined);
if (createdFlatFieldMetadataValidationResult.length > 0) {
const errors = createdFlatFieldMetadataValidationResult.map(
(validationResult) => validationResult.error,
);
const errors = createdFlatFieldMetadataValidationResult.flat();
throw new AggregateError(
throw new MultipleMetadataValidationErrors(
errors,
'Multiple validation errors occurred while creating field',
);
@@ -30,6 +30,7 @@ import { FieldMetadataMorphRelationService } from 'src/engine/metadata-modules/f
import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.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 { FieldMetadataServiceV2 } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2';
import { areFieldMetadatasTypeRelationOrMorphRelation } from 'src/engine/metadata-modules/field-metadata/utils/are-field-metadatas-type-relation-or-morph-relation.util';
import { assertDoesNotNullifyDefaultValueForNonNullableField } from 'src/engine/metadata-modules/field-metadata/utils/assert-does-not-nullify-default-value-for-non-nullable-field.util';
import { buildUpdatableStandardFieldInput } from 'src/engine/metadata-modules/field-metadata/utils/build-updatable-standard-field-input.util';
@@ -95,6 +96,7 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
private readonly fieldMetadataValidationService: FieldMetadataValidationService,
private readonly fieldMetadataMorphRelationService: FieldMetadataMorphRelationService,
private readonly fieldMetadataRelationService: FieldMetadataRelationService,
private readonly fieldMetadataServiceV2: FieldMetadataServiceV2,
) {
super(fieldMetadataRepository);
}
@@ -102,6 +104,16 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
override async createOne(
fieldMetadataInput: CreateFieldInput,
): Promise<FieldMetadataEntity> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
fieldMetadataInput.workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return this.fieldMetadataServiceV2.createOne(fieldMetadataInput);
}
const [createdFieldMetadata] = await this.createMany([fieldMetadataInput]);
if (!isDefined(createdFieldMetadata)) {
@@ -566,14 +578,22 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
if (!fieldMetadataInputs.length) {
return [];
}
const workspaceId = fieldMetadataInputs[0].workspaceId;
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
return this.fieldMetadataServiceV2.createMany(fieldMetadataInputs);
}
const { objectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps(
{ workspaceId: fieldMetadataInputs[0].workspaceId },
);
const workspaceId = fieldMetadataInputs[0].workspaceId;
const isMorphRelationEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from './services/flat-field-metadata-type-validator.service';
import { FlatFieldMetadataValidatorService } from './services/flat-field-metadata-validator.service';
@Module({
imports: [FeatureFlagModule],
providers: [
FlatFieldMetadataValidatorService,
FlatFieldMetadataTypeValidatorService,
],
exports: [
FlatFieldMetadataValidatorService,
FlatFieldMetadataTypeValidatorService,
],
})
export class FlatFieldMetadataModule {}
@@ -0,0 +1,129 @@
import { Injectable } from '@nestjs/common';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FlatFieldMetadataTypeValidator } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type';
import { isEnumValidateOneFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util';
import { validateEnumSelectFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator';
import { validateRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator';
@Injectable()
export class FlatFieldMetadataTypeValidatorService {
constructor(private readonly featureFlagService: FeatureFlagService) {}
public readonly FIELD_METADATA_TYPE_VALIDATOR_HASHMAP: FlatFieldMetadataTypeValidator =
{
ACTOR: async (_args) => {
return [];
},
ADDRESS: async (_args) => {
return [];
},
ARRAY: async (_args) => {
return [];
},
BOOLEAN: async (_args) => {
return [];
},
CURRENCY: async (_args) => {
return [];
},
DATE: async (_args) => {
return [];
},
DATE_TIME: async (_args) => {
return [];
},
EMAILS: async (_args) => {
return [];
},
FULL_NAME: async (_args) => {
return [];
},
LINKS: async (_args) => {
return [];
},
MORPH_RELATION: async ({ workspaceId }) => {
const isMorphRelationEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
workspaceId,
);
if (!isMorphRelationEnabled) {
return [
new FieldMetadataException(
'Morph relation feature is disabled',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
),
];
}
return [];
},
MULTI_SELECT: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return validateEnumSelectFlatFieldMetadata(args);
},
NUMBER: async (_args) => {
return [];
},
NUMERIC: async (_args) => {
return [];
},
PHONES: async (_args) => {
return [];
},
POSITION: async (_args) => {
return [];
},
RATING: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return validateEnumSelectFlatFieldMetadata(args);
},
RAW_JSON: async (_args) => {
return [];
},
RELATION: validateRelationFlatFieldMetadata,
RICH_TEXT: async (_args) => {
return [];
},
RICH_TEXT_V2: async (_args) => {
return [];
},
SELECT: (args) => {
if (!isEnumValidateOneFieldMetadata(args)) {
throw new FieldMetadataException(
'Should never occur, invaliad enum field metadata type',
FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return validateEnumSelectFlatFieldMetadata(args);
},
TEXT: async (_args) => {
return [];
},
TS_VECTOR: async (_args) => {
return [];
},
UUID: async (_args) => {
return [];
},
};
}
@@ -1,16 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Expect } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
@@ -25,25 +23,32 @@ import {
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
type ValidateOneFieldMetadataArgs = {
export type ValidateOneFieldMetadataArgs<
T extends FieldMetadataType = FieldMetadataType,
> = {
existingFlatObjectMetadatas: FlatObjectMetadata[];
othersFlatObjectMetadataToValidate?: FlatObjectMetadata[];
flatFieldMetadataToValidate: FlatFieldMetadata;
flatFieldMetadataToValidate: FlatFieldMetadata<T>;
workspaceId: string;
};
@Injectable()
export class FlatFieldMetadataValidatorService {
constructor(private readonly featureFlagService: FeatureFlagService) {}
constructor(
private readonly flatFieldMetadataTypeValidatorService: FlatFieldMetadataTypeValidatorService,
) {}
async validateOneFlatFieldMetadata({
async validateOneFlatFieldMetadata<
T extends FieldMetadataType = FieldMetadataType,
>({
existingFlatObjectMetadatas,
flatFieldMetadataToValidate,
othersFlatObjectMetadataToValidate,
workspaceId,
}: ValidateOneFieldMetadataArgs): Promise<
FailedFlatFieldMetadataValidation | undefined
}: ValidateOneFieldMetadataArgs<T>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> {
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
const allFlatObjectMetadata = [
...existingFlatObjectMetadatas,
...(othersFlatObjectMetadataToValidate ?? []),
@@ -55,25 +60,33 @@ export class FlatFieldMetadataValidatorService {
);
if (!isDefined(parentFlatObjectMetadata)) {
return {
status: 'fail',
error: new FieldMetadataException(
errors.push(
new FieldMetadataException(
isDefined(othersFlatObjectMetadataToValidate)
? 'Object metadata does not exist in both existing and about to be created object metadatas'
: 'Object metadata does not exist',
? 'Object metadata not found in both existing and about to be created object metadatas'
: 'Object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
};
}
);
} else {
if (parentFlatObjectMetadata.isRemote === true) {
errors.push(
new ObjectMetadataException(
'Remote objects are read-only',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
);
}
if (parentFlatObjectMetadata.isRemote === true) {
return {
status: 'fail',
error: new ObjectMetadataException(
'Remote objects are read-only',
ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED,
),
};
const failedNameAvailabilityValidation =
validateFlatFieldMetadataNameAvailability({
name: flatFieldMetadataToValidate.name,
objectMetadata: parentFlatObjectMetadata,
});
if (isDefined(failedNameAvailabilityValidation)) {
errors.push(failedNameAvailabilityValidation);
}
}
if (flatFieldMetadataToValidate.isLabelSyncedWithName) {
@@ -82,102 +95,54 @@ export class FlatFieldMetadataValidatorService {
);
if (flatFieldMetadataToValidate.name !== computedName) {
return {
status: 'fail',
error: new InvalidMetadataException(
errors.push(
new InvalidMetadataException(
`Name is not synced with label. Expected name: "${computedName}", got ${flatFieldMetadataToValidate.name}`,
InvalidMetadataExceptionCode.NAME_NOT_SYNCED_WITH_LABEL,
),
};
);
}
}
try {
validateMetadataNameOrThrow(flatFieldMetadataToValidate.name);
} catch (error) {
return {
status: 'fail',
error: new FieldMetadataException(
errors.push(
new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
),
};
);
}
const failedNameAvailabilityValidation =
validateFlatFieldMetadataNameAvailability({
name: flatFieldMetadataToValidate.name,
objectMetadata: parentFlatObjectMetadata,
});
const fieldMetadataTypeValidator =
this.flatFieldMetadataTypeValidatorService
.FIELD_METADATA_TYPE_VALIDATOR_HASHMAP[
flatFieldMetadataToValidate.type
];
if (isDefined(failedNameAvailabilityValidation)) {
return failedNameAvailabilityValidation;
if (!isDefined(fieldMetadataTypeValidator)) {
errors.push(
new FieldMetadataException(
'Unsupported field metadata type',
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
),
);
} else {
const fieldMetadataTypeValidatorExceptions =
await fieldMetadataTypeValidator({
existingFlatObjectMetadatas,
flatFieldMetadataToValidate,
workspaceId,
othersFlatObjectMetadataToValidate,
});
errors.push(...fieldMetadataTypeValidatorExceptions);
}
// We should validate each default value and settings and options
// We should also handle relation and stuff
switch (flatFieldMetadataToValidate.type) {
case FieldMetadataType.MORPH_RELATION: {
const isMorphRelationEnabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_MORPH_RELATION_ENABLED,
workspaceId,
);
if (!isMorphRelationEnabled) {
return {
status: 'fail',
error: new FieldMetadataException(
'Morph relation feature is disabled',
FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED,
),
};
}
return;
}
case FieldMetadataType.RELATION:
case FieldMetadataType.UUID:
case FieldMetadataType.TEXT:
case FieldMetadataType.PHONES:
case FieldMetadataType.EMAILS:
case FieldMetadataType.DATE_TIME:
case FieldMetadataType.DATE:
case FieldMetadataType.BOOLEAN:
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC:
case FieldMetadataType.LINKS:
case FieldMetadataType.CURRENCY:
case FieldMetadataType.FULL_NAME:
case FieldMetadataType.RATING:
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT:
case FieldMetadataType.POSITION:
case FieldMetadataType.ADDRESS:
case FieldMetadataType.RAW_JSON:
case FieldMetadataType.RICH_TEXT:
case FieldMetadataType.RICH_TEXT_V2:
case FieldMetadataType.ACTOR:
case FieldMetadataType.ARRAY:
case FieldMetadataType.TS_VECTOR: {
return;
}
default: {
const _staticTypeCheck: Expect<
typeof flatFieldMetadataToValidate.type extends never ? true : false
> = true;
return {
status: 'fail',
error: new FieldMetadataException(
'Unsupported field metadata type',
FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION,
),
};
}
}
return errors;
}
}
@@ -1,11 +1,45 @@
import { FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export type FailedFlatFieldMetadataValidation = {
status: 'fail';
error:
| FieldMetadataException
| ObjectMetadataException
| InvalidMetadataException;
export type FailedFlatFieldMetadataValidationExceptions =
| FieldMetadataException
| ObjectMetadataException
| InvalidMetadataException;
export type FlatFieldMetadataValidator<T> = {
validator: (value: T) => boolean;
message: string;
};
export const runFlatFieldMetadataValidator = <T>(
elementToValidate: T,
{ message, validator }: FlatFieldMetadataValidator<T>,
): FailedFlatFieldMetadataValidationExceptions | undefined => {
const validationFailed = validator(elementToValidate);
if (validationFailed) {
return new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
};
export const runFlatFieldMetadataValidators = <T>(
elementToValidate: T,
validators: FlatFieldMetadataValidator<T>[],
): FailedFlatFieldMetadataValidationExceptions[] =>
validators
.map((validator) =>
runFlatFieldMetadataValidator(elementToValidate, validator),
)
.filter(isDefined);
@@ -0,0 +1,12 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
export type FlatFieldMetadataTypeValidator = {
[P in FieldMetadataType]: (
args: ValidateOneFieldMetadataArgs<P>,
) =>
| FailedFlatFieldMetadataValidationExceptions[]
| Promise<FailedFlatFieldMetadataValidationExceptions[]>;
};
@@ -1,4 +1,4 @@
import { sanitizeObjectStringFields } from 'twenty-shared/utils';
import { extractAndSanitizeObjectStringFields } from 'twenty-shared/utils';
import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input';
import { generateNullable } from 'src/engine/metadata-modules/field-metadata/utils/generate-nullable';
@@ -12,7 +12,7 @@ export const getDefaultFlatFieldMetadata = ({
createFieldInput,
fieldMetadataId,
}: GetDefaultFlatFieldMetadataArgs) => {
const { defaultValue, settings } = sanitizeObjectStringFields(
const { defaultValue, settings } = extractAndSanitizeObjectStringFields(
createFieldInput,
['defaultValue', 'settings'],
);
@@ -0,0 +1,10 @@
import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types';
import { isEnumFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-enum-field-metadata-type.util';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
export const isEnumValidateOneFieldMetadata = <T extends FieldMetadataType>(
args: ValidateOneFieldMetadataArgs<T>,
): args is ValidateOneFieldMetadataArgs<T> &
ValidateOneFieldMetadataArgs<EnumFieldMetadataType> =>
isEnumFieldMetadataType(args.flatFieldMetadataToValidate.type);
@@ -0,0 +1,260 @@
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { QUOTED_STRING_REGEX } from 'twenty-shared/constants';
import {
EnumFieldMetadataType,
FieldMetadataType,
NonNullableRequired,
} from 'twenty-shared/types';
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 {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import {
FailedFlatFieldMetadataValidationExceptions,
FlatFieldMetadataValidator,
runFlatFieldMetadataValidators,
} from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
const validateMetadataOptionId = (sanitizedId?: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
{
validator: (id) => !isDefined(id),
message: t`Option id is required`,
},
{
validator: (id) => !z.string().uuid().safeParse(id).success,
message: t`Option id is invalid`,
},
];
return runFlatFieldMetadataValidators(sanitizedId, validators);
};
const validateMetadataOptionLabel = (sanitizedLabel: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
{
validator: (label) => !isDefined(label),
message: t`Option label is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: t`Option label exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: t`Option label "${sanitizedLabel}" is beneath 1 character`,
},
{
validator: (label) => label.includes(','),
message: t`Label must not contain a comma`,
},
{
validator: (label) => !isNonEmptyString(label) || label === ' ',
message: t`Label must not be empty`,
},
];
return runFlatFieldMetadataValidators(sanitizedLabel, validators);
};
const validateMetadataOptionValue = (sanitizedValue: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
{
validator: (value) => !isDefined(value),
message: t`Option value is required`,
},
{
validator: exceedsDatabaseIdentifierMaximumLength,
message: t`Option value exceeds 63 characters`,
},
{
validator: beneathDatabaseIdentifierMinimumLength,
message: t`Option value "${sanitizedValue}" is beneath 1 character`,
},
{
validator: (value) => !isSnakeCaseString(value),
message: t`Value must be in UPPER_CASE and follow snake_case "${sanitizedValue}"`,
},
];
return runFlatFieldMetadataValidators(sanitizedValue, validators);
};
const validateDuplicates = (
options: FieldMetadataOptions<EnumFieldMetadataType>,
) => {
const fieldsToCheckForDuplicates = [
'position',
'id',
'value',
] as const satisfies (keyof (
| FieldMetadataDefaultOption[]
| FieldMetadataComplexOption[]
)[number])[];
const duplicatedValidators = fieldsToCheckForDuplicates.map<
FlatFieldMetadataValidator<
FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]
>
>((field) => ({
message: t`Duplicated option ${field}`,
validator: () =>
new Set(options.map((option) => option[field])).size !== options.length,
}));
return runFlatFieldMetadataValidators(options, duplicatedValidators);
};
const validateFieldMetadataInputOptions = <T extends EnumFieldMetadataType>(
flatFieldMetadata: FlatFieldMetadata<T>,
) => {
const { options } = flatFieldMetadata;
if (!isDefined(options) || options.length === 0) {
return [
new FieldMetadataException(
'Options are required for enum fields',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
];
}
const optionsValidationErrors = options.flatMap((option) =>
[
validateMetadataOptionId(option.id),
validateMetadataOptionValue(option.value),
validateMetadataOptionLabel(option.label),
].flat(),
);
const duplicatedValidationErrors = validateDuplicates(options);
return [...optionsValidationErrors, ...duplicatedValidationErrors];
};
const validateSelectDefaultValue = (
options: FieldMetadataOptions<EnumFieldMetadataType>,
defaultValue: string | string[],
): FailedFlatFieldMetadataValidationExceptions[] => {
if (typeof defaultValue !== 'string') {
return [
new FieldMetadataException(
'Default value for select must be a string',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
];
}
const validators: FlatFieldMetadataValidator<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`,
},
];
return runFlatFieldMetadataValidators(defaultValue, validators);
};
const validateMultiSelectDefaultValue = (
options: FieldMetadataOptions<EnumFieldMetadataType>,
multiSelectDefaultValue: unknown,
): FailedFlatFieldMetadataValidationExceptions[] => {
if (!Array.isArray(multiSelectDefaultValue)) {
return [
new FieldMetadataException(
'Default value for multi-select must be an array',
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
];
}
const validators: FlatFieldMetadataValidator<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',
},
];
return [
runFlatFieldMetadataValidators(multiSelectDefaultValue, validators),
multiSelectDefaultValue.flatMap((value) =>
validateSelectDefaultValue(options, value),
),
].flat();
};
const validateFieldMetadataDefaultValue = (
flatFieldMetadata: Omit<
FlatFieldMetadata<EnumFieldMetadataType>,
'defaultValue'
> &
NonNullableRequired<
Pick<FlatFieldMetadata<EnumFieldMetadataType>, 'defaultValue'>
>,
) => {
switch (flatFieldMetadata.type) {
case FieldMetadataType.SELECT:
case FieldMetadataType.RATING:
return validateSelectDefaultValue(
flatFieldMetadata.options,
flatFieldMetadata.defaultValue,
);
case FieldMetadataType.MULTI_SELECT:
return validateMultiSelectDefaultValue(
flatFieldMetadata.options,
flatFieldMetadata.defaultValue,
);
default: {
assertUnreachable(
flatFieldMetadata.type,
'Should never occur, unknown field metadata enum type',
);
}
}
};
export const validateEnumSelectFlatFieldMetadata = ({
flatFieldMetadataToValidate,
}: ValidateOneFieldMetadataArgs<EnumFieldMetadataType>): FailedFlatFieldMetadataValidationExceptions[] => {
const optionsValidationErrors = validateFieldMetadataInputOptions(
flatFieldMetadataToValidate,
);
const defaultValueValidationErrors = isDefined(
flatFieldMetadataToValidate.defaultValue,
)
? validateFieldMetadataDefaultValue({
...flatFieldMetadataToValidate,
defaultValue: flatFieldMetadataToValidate.defaultValue,
})
: [];
return [...optionsValidationErrors, ...defaultValueValidationErrors];
};
@@ -5,7 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-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 { FailedFlatFieldMetadataValidation } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import {
InvalidMetadataException,
@@ -41,7 +41,7 @@ export const validateFlatFieldMetadataNameAvailability = ({
}: {
name: string;
objectMetadata: FlatObjectMetadata;
}): FailedFlatFieldMetadataValidation | undefined => {
}): FailedFlatFieldMetadataValidationExceptions | undefined => {
const reservedCompositeFieldsNames =
getReservedCompositeFieldNames(objectMetadata);
@@ -53,29 +53,23 @@ export const validateFlatFieldMetadataNameAvailability = ({
`${field.name}Id` === name),
)
) {
return {
status: 'fail',
error: new InvalidMetadataException(
`Name "${name}" is not available as it is already used by another field`,
InvalidMetadataExceptionCode.NOT_AVAILABLE,
{
userFriendlyMessage: t`This name is not available as it is already used by another field`,
},
),
};
return new InvalidMetadataException(
`Name "${name}" is not available as it is already used by another field`,
InvalidMetadataExceptionCode.NOT_AVAILABLE,
{
userFriendlyMessage: t`This name is not available as it is already used by another field`,
},
);
}
if (reservedCompositeFieldsNames.includes(name)) {
return {
status: 'fail',
error: new InvalidMetadataException(
`Name "${name}" is not available`,
InvalidMetadataExceptionCode.RESERVED_KEYWORD,
{
userFriendlyMessage: t`This name is not available.`,
},
),
};
return new InvalidMetadataException(
`Name "${name}" is not available`,
InvalidMetadataExceptionCode.RESERVED_KEYWORD,
{
userFriendlyMessage: t`This name is not available.`,
},
);
}
return undefined;
@@ -0,0 +1,82 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { mergeTwoFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util';
// Remark: This is duplicated with api metadata transpilers exceptions handlers
// We might wanna have an NotValidatedFlatFieldMetadata and NotValidatedFlatObjectMetadata, in order to avoid
export const validateRelationFlatFieldMetadata = async ({
existingFlatObjectMetadatas,
flatFieldMetadataToValidate: {
relationTargetFieldMetadataId,
relationTargetObjectMetadataId,
},
othersFlatObjectMetadataToValidate,
}: ValidateOneFieldMetadataArgs<FieldMetadataType.RELATION>): Promise<
FailedFlatFieldMetadataValidationExceptions[]
> => {
const uuidsValidation = [
relationTargetObjectMetadataId,
relationTargetFieldMetadataId,
].flatMap<FieldMetadataException>((id) =>
isValidUuid(id)
? []
: new FieldMetadataException(
`Invalid uuid ${id}`,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
),
);
if (uuidsValidation.length > 0) {
return uuidsValidation;
}
const errors: FailedFlatFieldMetadataValidationExceptions[] = [];
const allFlatObjectMetadata = isDefined(othersFlatObjectMetadataToValidate)
? mergeTwoFlatObjectMetadatas({
destFlatObjectMetadatas: existingFlatObjectMetadatas,
toMergeFlatObjectMetadatas: othersFlatObjectMetadataToValidate,
})
: existingFlatObjectMetadatas;
const targetRelationFlatObjectMetadata = allFlatObjectMetadata.find(
(flatObjectMetadata) =>
flatObjectMetadata.id === relationTargetObjectMetadataId,
);
if (!isDefined(targetRelationFlatObjectMetadata)) {
errors.push(
new FieldMetadataException(
isDefined(othersFlatObjectMetadataToValidate)
? 'Relation target object metadata not found in both existing and about to be created object metadatas'
: 'Relation target object metadata not found',
FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
),
);
}
const targetRelationFlatFieldMetadata =
targetRelationFlatObjectMetadata?.flatFieldMetadatas.find(
(flatFieldMetadata) =>
flatFieldMetadata.id === relationTargetFieldMetadataId,
);
if (!isDefined(targetRelationFlatFieldMetadata)) {
errors.push(
new FieldMetadataException(
isDefined(othersFlatObjectMetadataToValidate)
? 'Relation field target metadata not found in both existing and about to be created field metadatas'
: 'Relation field target metadata not found',
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
),
);
}
return errors;
};
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldMetadataType } from 'twenty-shared/types';
import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types';
import { WorkspaceColumnActionOptions } from 'src/engine/metadata-modules/workspace-migration/interfaces/workspace-column-action-options.interface';
@@ -19,11 +19,6 @@ import {
WorkspaceMigrationExceptionCode,
} from 'src/engine/metadata-modules/workspace-migration/workspace-migration.exception';
export type EnumFieldMetadataType =
| FieldMetadataType.RATING
| FieldMetadataType.SELECT
| FieldMetadataType.MULTI_SELECT;
@Injectable()
export class EnumColumnActionFactory extends ColumnActionAbstractFactory<EnumFieldMetadataType> {
protected readonly logger = new Logger(EnumColumnActionFactory.name);
@@ -49,6 +49,7 @@ export class WorkspaceMetadataObjectActionRunnerService
...flatObjectMetadataWithoutFields,
dataSourceId: lastDataSourceMetadata.id,
targetTableName: 'DEPRECATED',
// TODO call for each provided field too or pass fields here
});
};
@@ -7,14 +7,13 @@ import { createOneObjectMetadata } from 'test/integration/metadata/suites/object
import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
import { getMockCreateObjectInput } from 'test/integration/metadata/suites/object-metadata/utils/generate-mock-create-object-metadata-input';
import { EachTestingContext } from 'twenty-shared/testing';
import { FieldMetadataType } from 'twenty-shared/types';
import { EnumFieldMetadataType, FieldMetadataType } from 'twenty-shared/types';
import { isDefined, parseJson } from 'twenty-shared/utils';
import {
FieldMetadataComplexOption,
FieldMetadataDefaultOption,
} from 'src/engine/metadata-modules/field-metadata/dtos/options.input';
import { EnumFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory';
type Option = FieldMetadataDefaultOption | FieldMetadataComplexOption;
@@ -0,0 +1 @@
export const QUOTED_STRING_REGEX = /^['"](.*)['"]$/;
@@ -14,6 +14,7 @@ export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMet
export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { QUERY_MAX_RECORDS } from './QueryMaxRecords';
export { QUOTED_STRING_REGEX } from './QuotedStringRegex';
export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword';
export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions';
export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl';
@@ -0,0 +1,6 @@
import { FieldMetadataType } from './FieldMetadataType';
export type EnumFieldMetadataType =
| FieldMetadataType.RATING
| FieldMetadataType.SELECT
| FieldMetadataType.MULTI_SELECT;
@@ -0,0 +1,3 @@
export type NonNullableRequired<T> = {
[P in keyof T]-?: NonNullable<T[P]>;
};
@@ -1,3 +1,3 @@
export type NullablePartial<T> = {
[P in keyof T]: T[P] | null;
};
};
@@ -9,9 +9,11 @@
export type { ConfigVariableValue } from './ConfigVariableValue';
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
export type { EnumFieldMetadataType } from './EnumFieldMetadataType';
export { FieldMetadataType } from './FieldMetadataType';
export type { FromTo } from './FromToType';
export type { IsExactly } from './IsExactly';
export type { NonNullableRequired } from './NonNullableRequired';
export type { NullablePartial } from './NullablePartial';
export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions';
export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId';
@@ -11,7 +11,9 @@ describe('deepMerge', () => {
describe('primitive values', () => {
type PrimitiveValue = { value: string | number | boolean };
const primitiveTestCases: EachTestingContext<DeepMergeTestCase<PrimitiveValue>>[] = [
const primitiveTestCases: EachTestingContext<
DeepMergeTestCase<PrimitiveValue>
>[] = [
{
title: 'should override string values',
context: {
@@ -38,15 +40,20 @@ describe('deepMerge', () => {
},
];
it.each(primitiveTestCases)('$title', ({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
});
it.each(primitiveTestCases)(
'$title',
({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
},
);
});
describe('null and undefined handling', () => {
type NullableValue = { value: string | null };
const nullTestCases: EachTestingContext<DeepMergeTestCase<NullableValue>>[] = [
const nullTestCases: EachTestingContext<
DeepMergeTestCase<NullableValue>
>[] = [
{
title: 'should preserve null values from target',
context: {
@@ -73,13 +80,18 @@ describe('deepMerge', () => {
},
];
it.each(nullTestCases)('$title', ({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
});
it.each(nullTestCases)(
'$title',
({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
},
);
type MixedNullValue = { a: number | null; b: number };
const mixedNullTestCase: EachTestingContext<DeepMergeTestCase<MixedNullValue>> = {
const mixedNullTestCase: EachTestingContext<
DeepMergeTestCase<MixedNullValue>
> = {
title: 'should handle mixed null and undefined values',
context: {
source: { a: 1, b: 2 },
@@ -97,44 +109,48 @@ describe('deepMerge', () => {
describe('array handling', () => {
type ArrayValue = { arr: Array<string | number> | null };
const arrayTestCases: EachTestingContext<DeepMergeTestCase<ArrayValue>>[] = [
{
title: 'should concatenate arrays',
context: {
source: { arr: [1, 2] },
target: { arr: [3, 4] },
expected: { arr: [1, 2, 3, 4] },
const arrayTestCases: EachTestingContext<DeepMergeTestCase<ArrayValue>>[] =
[
{
title: 'should concatenate arrays',
context: {
source: { arr: [1, 2] },
target: { arr: [3, 4] },
expected: { arr: [1, 2, 3, 4] },
},
},
},
{
title: 'should handle empty target array',
context: {
source: { arr: [1, 2] },
target: { arr: [] },
expected: { arr: [1, 2] },
{
title: 'should handle empty target array',
context: {
source: { arr: [1, 2] },
target: { arr: [] },
expected: { arr: [1, 2] },
},
},
},
{
title: 'should handle empty source array',
context: {
source: { arr: [] },
target: { arr: [1, 2] },
expected: { arr: [1, 2] },
{
title: 'should handle empty source array',
context: {
source: { arr: [] },
target: { arr: [1, 2] },
expected: { arr: [1, 2] },
},
},
},
{
title: 'should handle null target array',
context: {
source: { arr: ['a', 'b'] },
target: { arr: null },
expected: { arr: null },
{
title: 'should handle null target array',
context: {
source: { arr: ['a', 'b'] },
target: { arr: null },
expected: { arr: null },
},
},
},
];
];
it.each(arrayTestCases)('$title', ({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
});
it.each(arrayTestCases)(
'$title',
({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
},
);
});
describe('nested objects', () => {
@@ -154,7 +170,9 @@ describe('deepMerge', () => {
} | null;
};
const nestedTestCases: EachTestingContext<DeepMergeTestCase<NestedValue>>[] = [
const nestedTestCases: EachTestingContext<
DeepMergeTestCase<NestedValue>
>[] = [
{
title: 'should merge nested objects',
context: {
@@ -166,9 +184,33 @@ describe('deepMerge', () => {
{
title: 'should merge deeply nested objects',
context: {
source: { nested: { a: 1, b: 2, deep: { a: 1, b: 2 }, arr: [], obj: { a: 1, b: 2 } } },
target: { nested: { a: 1, b: 2, deep: { a: 1, b: 3 }, arr: [], obj: { a: 1, b: 2 } } },
expected: { nested: { a: 1, b: 2, deep: { a: 1, b: 3 }, arr: [], obj: { a: 1, b: 2 } } },
source: {
nested: {
a: 1,
b: 2,
deep: { a: 1, b: 2 },
arr: [],
obj: { a: 1, b: 2 },
},
},
target: {
nested: {
a: 1,
b: 2,
deep: { a: 1, b: 3 },
arr: [],
obj: { a: 1, b: 2 },
},
},
expected: {
nested: {
a: 1,
b: 2,
deep: { a: 1, b: 3 },
arr: [],
obj: { a: 1, b: 2 },
},
},
},
},
{
@@ -210,9 +252,12 @@ describe('deepMerge', () => {
},
];
it.each(nestedTestCases)('$title', ({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
});
it.each(nestedTestCases)(
'$title',
({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
},
);
});
describe('edge cases', () => {
@@ -248,27 +293,32 @@ describe('deepMerge', () => {
},
];
it.each(edgeTestCases)('$title', ({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
});
it.each(edgeTestCases)(
'$title',
({ context: { source, target, expected } }) => {
expect(deepMerge(source, target)).toEqual(expected);
},
);
type NestedDateValue = {
a: { value: Date };
b: { value: Date };
};
const nestedDateTestCase: EachTestingContext<DeepMergeTestCase<NestedDateValue>> = {
const nestedDateTestCase: EachTestingContext<
DeepMergeTestCase<NestedDateValue>
> = {
title: 'should handle Date objects in nested structures',
context: {
source: {
source: {
a: { value: new Date('2023-01-01') },
b: { value: new Date('2023-01-01') },
},
target: {
target: {
a: { value: new Date('2023-12-31') },
b: { value: new Date('2023-12-31') },
},
expected: {
expected: {
a: { value: new Date('2023-12-31') },
b: { value: new Date('2023-12-31') },
},
@@ -1,5 +1,6 @@
import { eachTestingContextFilter } from '@/testing';
import { EachTestingContext } from '@/testing/types/EachTestingContext.type';
import { sanitizeObjectStringFields } from '../sanitizeObjectStringFields';
import { extractAndSanitizeObjectStringFields } from '../extractAndSanitizeObjectStringFields';
type TestObject = {
name?: string;
@@ -25,7 +26,7 @@ type SanitizeTestCase = EachTestingContext<{
expected: object;
}>;
describe('sanitizeObjectStringFields', () => {
describe('extractAndSanitizeObjectStringFields', () => {
const testCases: SanitizeTestCase[] = [
{
title: 'should handle basic string properties and trim whitespaces',
@@ -84,6 +85,16 @@ describe('sanitizeObjectStringFields', () => {
obj: { name: 'John', age: 30 },
keys: ['city', 'name'],
},
expected: { name: 'John' },
},
},
{
title: 'should handle object with number field',
context: {
input: {
obj: { name: 'John', age: 30 },
keys: ['age', 'name'],
},
expected: { name: 'John', age: 30 },
},
},
@@ -164,7 +175,7 @@ describe('sanitizeObjectStringFields', () => {
},
];
test.each(testCases)(
test.each(eachTestingContextFilter(testCases))(
'$title',
({
context: {
@@ -172,7 +183,7 @@ describe('sanitizeObjectStringFields', () => {
expected,
},
}) => {
const result = sanitizeObjectStringFields(obj, keys);
const result = extractAndSanitizeObjectStringFields(obj, keys);
expect(result).toEqual(expected);
},
@@ -6,7 +6,7 @@
* - Null values from target are preserved
* - Undefined values from target are ignored
* - Date and RegExp objects are treated as primitives (replaced, not merged)
*
*
* @param source The source object to merge from
* @param target The target object to merge into
* @returns A new merged object
@@ -76,4 +76,4 @@ export const deepMerge = <T extends object>(
});
return output;
};
};
@@ -1,12 +1,12 @@
import { trimAndRemoveDuplicatedWhitespacesFromString } from '@/utils/trim-and-remove-duplicated-whitespaces-from-string';
// TODO rename with extract meaning
export const sanitizeObjectStringFields = <
export const extractAndSanitizeObjectStringFields = <
T extends object,
TKeys extends (keyof T)[],
>(
obj: T,
keys: TKeys,
maxDepth: number = 10,
maxDepth = 10,
): {
[P in TKeys[number]]: T[P];
} => {
+1 -1
View File
@@ -9,6 +9,7 @@
export { assertUnreachable } from './assertUnreachable';
export { deepMerge } from './deepMerge';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { fromArrayToUniqueKeyRecord } from './from-array-to-unique-key-record.util';
export { getURLSafely } from './getURLSafely';
@@ -21,7 +22,6 @@ export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraints
export { parseJson } from './parseJson';
export { removePropertiesFromRecord } from './removePropertiesFromRecord';
export { removeUndefinedFields } from './removeUndefinedFields';
export { sanitizeObjectStringFields } from './sanitizeObjectStringFields';
export { getGenericOperationName } from './sentry/getGenericOperationName';
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
export { capitalize } from './strings/capitalize';
@@ -1,4 +1,5 @@
import { isNull, isUndefined } from '@sniptt/guards';
export const isDefined = <T>(value: T | null | undefined): value is T =>
!isUndefined(value) && !isNull(value);
export const isDefined = <T>(
value: T | null | undefined,
): value is NonNullable<T> => !isUndefined(value) && !isNull(value);