From 14537d74b4cefd954b9b53d2b780ec76e928d5a2 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 31 Jul 2025 14:54:39 +0200 Subject: [PATCH] `FlatFieldMetadataType` hashmap arch and enum validation (#13502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 --- ...=> multiple-metadata-validation-errors.ts} | 2 +- .../field-metadata/field-metadata.module.ts | 10 + .../field-metadata-enum-validation.service.ts | 3 +- .../services/field-metadata.service-v2.ts | 27 +- .../services/field-metadata.service.ts | 24 +- .../flat-field-metadata.module.ts | 19 ++ ...t-field-metadata-type-validator.service.ts | 129 +++++++++ .../flat-field-metadata-validator.service.ts | 169 +++++------- ...led-flat-field-metadata-validation.type.ts | 48 +++- ...flat-field-metadata-type-validator.type.ts | 12 + ...d-metadata-from-create-field-input.util.ts | 4 +- ...m-validate-one-field-metadata-args.util.ts | 10 + ...date-enum-flat-field-metadata.validator.ts | 260 ++++++++++++++++++ ...ld-metadata-name-availability.validator.ts | 38 ++- ...-relation-flat-field-metadata.validator.ts | 82 ++++++ .../factories/enum-column-action.factory.ts | 7 +- ...e-metadata-object-action-runner.service.ts | 1 + ...etadata-related-record.integration-spec.ts | 3 +- .../src/constants/QuotedStringRegex.ts | 1 + packages/twenty-shared/src/constants/index.ts | 1 + .../src/types/EnumFieldMetadataType.ts | 6 + .../src/types/NonNullableRequired.ts | 3 + .../src/types/NullablePartial.ts | 2 +- packages/twenty-shared/src/types/index.ts | 2 + .../src/utils/__tests__/deepMerge.test.ts | 162 +++++++---- .../sanitizeObjectStringFields.test.ts | 19 +- packages/twenty-shared/src/utils/deepMerge.ts | 4 +- ...> extractAndSanitizeObjectStringFields.ts} | 6 +- packages/twenty-shared/src/utils/index.ts | 2 +- .../src/utils/validation/isDefined.ts | 5 +- 30 files changed, 841 insertions(+), 220 deletions(-) rename packages/twenty-server/src/engine/core-modules/error/{aggregate-error.ts => multiple-metadata-validation-errors.ts} (72%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/flat-field-metadata.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts create mode 100644 packages/twenty-shared/src/constants/QuotedStringRegex.ts create mode 100644 packages/twenty-shared/src/types/EnumFieldMetadataType.ts create mode 100644 packages/twenty-shared/src/types/NonNullableRequired.ts rename packages/twenty-shared/src/utils/{sanitizeObjectStringFields.ts => extractAndSanitizeObjectStringFields.ts} (92%) diff --git a/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts b/packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts similarity index 72% rename from packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts rename to packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts index 2af8ea9068f..170121ab5c1 100644 --- a/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts +++ b/packages/twenty-server/src/engine/core-modules/error/multiple-metadata-validation-errors.ts @@ -1,4 +1,4 @@ -export class AggregateError extends Error { +export class MultipleMetadataValidationErrors extends Error { constructor( public readonly errors: Error[], message = 'Multiple errors occurred', diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index cecba278c11..b29f9564226 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts index eb766e644a6..bfee736abc3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-enum-validation.service.ts @@ -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 = { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts index 09721d3c907..fbdcc745b48 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts @@ -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 { + 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 { @@ -72,11 +91,9 @@ export class FieldMetadataServiceV2 extends TypeOrmQueryService 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', ); diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index bfc2adc69bd..78771b6a00a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -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 { + 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 { + 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 []; + }, + }; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts index a2bb1faf026..5d266d029e2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts @@ -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; 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): 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; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts index 758342769ee..30364e58cc8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts @@ -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 = { + validator: (value: T) => boolean; + message: string; }; + +export const runFlatFieldMetadataValidator = ( + elementToValidate: T, + { message, validator }: FlatFieldMetadataValidator, +): FailedFlatFieldMetadataValidationExceptions | undefined => { + const validationFailed = validator(elementToValidate); + + if (validationFailed) { + return new FieldMetadataException( + message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: message, + }, + ); + } +}; + +export const runFlatFieldMetadataValidators = ( + elementToValidate: T, + validators: FlatFieldMetadataValidator[], +): FailedFlatFieldMetadataValidationExceptions[] => + validators + .map((validator) => + runFlatFieldMetadataValidator(elementToValidate, validator), + ) + .filter(isDefined); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts new file mode 100644 index 00000000000..e762051ff9e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type.ts @@ -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

, + ) => + | FailedFlatFieldMetadataValidationExceptions[] + | Promise; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts index f65e8dbc900..fe5cd938072 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -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'], ); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts new file mode 100644 index 00000000000..538498a0bf7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/is-enum-validate-one-field-metadata-args.util.ts @@ -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 = ( + args: ValidateOneFieldMetadataArgs, +): args is ValidateOneFieldMetadataArgs & + ValidateOneFieldMetadataArgs => + isEnumFieldMetadataType(args.flatFieldMetadataToValidate.type); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts new file mode 100644 index 00000000000..21efc5bdddd --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-enum-flat-field-metadata.validator.ts @@ -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[] = [ + { + 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[] = [ + { + 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[] = [ + { + 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, +) => { + 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 = ( + flatFieldMetadata: FlatFieldMetadata, +) => { + 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, + 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[] = [ + { + 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, + 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[] = [ + { + 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, + 'defaultValue' + > & + NonNullableRequired< + Pick, '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): FailedFlatFieldMetadataValidationExceptions[] => { + const optionsValidationErrors = validateFieldMetadataInputOptions( + flatFieldMetadataToValidate, + ); + + const defaultValueValidationErrors = isDefined( + flatFieldMetadataToValidate.defaultValue, + ) + ? validateFieldMetadataDefaultValue({ + ...flatFieldMetadataToValidate, + defaultValue: flatFieldMetadataToValidate.defaultValue, + }) + : []; + + return [...optionsValidationErrors, ...defaultValueValidationErrors]; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts index 8bd4f88ac9f..588be387042 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts new file mode 100644 index 00000000000..0daf457fd9f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-relation-flat-field-metadata.validator.ts @@ -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): Promise< + FailedFlatFieldMetadataValidationExceptions[] +> => { + const uuidsValidation = [ + relationTargetObjectMetadataId, + relationTargetFieldMetadataId, + ].flatMap((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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts index f4e93610229..09aa5e55997 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/factories/enum-column-action.factory.ts @@ -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 { protected readonly logger = new Logger(EnumColumnActionFactory.name); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts index ea89cfb549e..402e4152fa9 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-metadata-migration-runner/workspace-metadata-object-action-runner.service.ts @@ -49,6 +49,7 @@ export class WorkspaceMetadataObjectActionRunnerService ...flatObjectMetadataWithoutFields, dataSourceId: lastDataSourceMetadata.id, targetTableName: 'DEPRECATED', + // TODO call for each provided field too or pass fields here }); }; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts index de0220ee183..728df6d9932 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-related-record.integration-spec.ts @@ -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; diff --git a/packages/twenty-shared/src/constants/QuotedStringRegex.ts b/packages/twenty-shared/src/constants/QuotedStringRegex.ts new file mode 100644 index 00000000000..82705232491 --- /dev/null +++ b/packages/twenty-shared/src/constants/QuotedStringRegex.ts @@ -0,0 +1 @@ +export const QUOTED_STRING_REGEX = /^['"](.*)['"]$/; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index 448031a871d..c7a000e3348 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/types/EnumFieldMetadataType.ts b/packages/twenty-shared/src/types/EnumFieldMetadataType.ts new file mode 100644 index 00000000000..b0c66fe2c54 --- /dev/null +++ b/packages/twenty-shared/src/types/EnumFieldMetadataType.ts @@ -0,0 +1,6 @@ +import { FieldMetadataType } from './FieldMetadataType'; + +export type EnumFieldMetadataType = + | FieldMetadataType.RATING + | FieldMetadataType.SELECT + | FieldMetadataType.MULTI_SELECT; diff --git a/packages/twenty-shared/src/types/NonNullableRequired.ts b/packages/twenty-shared/src/types/NonNullableRequired.ts new file mode 100644 index 00000000000..88300116d3d --- /dev/null +++ b/packages/twenty-shared/src/types/NonNullableRequired.ts @@ -0,0 +1,3 @@ +export type NonNullableRequired = { + [P in keyof T]-?: NonNullable; +}; diff --git a/packages/twenty-shared/src/types/NullablePartial.ts b/packages/twenty-shared/src/types/NullablePartial.ts index 369da30f3fe..5b05dd7d0b3 100644 --- a/packages/twenty-shared/src/types/NullablePartial.ts +++ b/packages/twenty-shared/src/types/NullablePartial.ts @@ -1,3 +1,3 @@ export type NullablePartial = { [P in keyof T]: T[P] | null; -}; \ No newline at end of file +}; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index bb95c46cc4e..ae7c777897a 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts b/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts index 2762d0608dc..9ca7a878674 100644 --- a/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/deepMerge.test.ts @@ -11,7 +11,9 @@ describe('deepMerge', () => { describe('primitive values', () => { type PrimitiveValue = { value: string | number | boolean }; - const primitiveTestCases: EachTestingContext>[] = [ + const primitiveTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { 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>[] = [ + const nullTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { 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> = { + const mixedNullTestCase: EachTestingContext< + DeepMergeTestCase + > = { 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 | null }; - const arrayTestCases: EachTestingContext>[] = [ - { - title: 'should concatenate arrays', - context: { - source: { arr: [1, 2] }, - target: { arr: [3, 4] }, - expected: { arr: [1, 2, 3, 4] }, + const arrayTestCases: EachTestingContext>[] = + [ + { + 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>[] = [ + const nestedTestCases: EachTestingContext< + DeepMergeTestCase + >[] = [ { 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> = { + const nestedDateTestCase: EachTestingContext< + DeepMergeTestCase + > = { 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') }, }, diff --git a/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts index 6b548de8297..73abbfd9f3d 100644 --- a/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts @@ -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); }, diff --git a/packages/twenty-shared/src/utils/deepMerge.ts b/packages/twenty-shared/src/utils/deepMerge.ts index f10b40ad81d..8b50d14caad 100644 --- a/packages/twenty-shared/src/utils/deepMerge.ts +++ b/packages/twenty-shared/src/utils/deepMerge.ts @@ -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 = ( }); return output; -}; \ No newline at end of file +}; diff --git a/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts b/packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts similarity index 92% rename from packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts rename to packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts index 968d5b9153a..2e234632534 100644 --- a/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts +++ b/packages/twenty-shared/src/utils/extractAndSanitizeObjectStringFields.ts @@ -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]; } => { diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 81b73ab1200..1bd9e519651 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -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'; diff --git a/packages/twenty-shared/src/utils/validation/isDefined.ts b/packages/twenty-shared/src/utils/validation/isDefined.ts index 81eb67203a0..aa97cf342fb 100644 --- a/packages/twenty-shared/src/utils/validation/isDefined.ts +++ b/packages/twenty-shared/src/utils/validation/isDefined.ts @@ -1,4 +1,5 @@ import { isNull, isUndefined } from '@sniptt/guards'; -export const isDefined = (value: T | null | undefined): value is T => - !isUndefined(value) && !isNull(value); +export const isDefined = ( + value: T | null | undefined, +): value is NonNullable => !isUndefined(value) && !isNull(value);