diff --git a/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts b/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts new file mode 100644 index 0000000000..2af8ea9068 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/error/aggregate-error.ts @@ -0,0 +1,9 @@ +export class AggregateError extends Error { + constructor( + public readonly errors: Error[], + message = 'Multiple errors occurred', + ) { + super(message); + this.name = 'AggregateError'; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts index a91b32137f..c07ad19818 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts @@ -118,9 +118,6 @@ export class FieldMetadataDTO { @Field({ nullable: true }) isUnique?: boolean; - // TODO: This validator was not used anymore, and it is since graphql error hadling refactoring - // it is adding extra load on the database, we are still validing inputs on field update and create - // @Validate(IsFieldMetadataDefaultValue) @IsOptional() @Field(() => GraphQLJSON, { nullable: true }) defaultValue?: FieldMetadataDefaultValue; @@ -128,9 +125,6 @@ export class FieldMetadataDTO { @Transform(({ value }) => transformEnumValue(value as FieldMetadataDefaultOption[]), ) - // TODO: This validator was not used anymore, and it is since graphql error hadling refactoring - // it is adding extra load on the database, we are still validing inputs on field update and create - // @Validate(IsFieldMetadataOptions) @IsOptional() @Field(() => GraphQLJSON, { nullable: true }) options?: FieldMetadataOptions; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.exception.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.exception.ts index d2ac95ef69..b22b43c40f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.exception.ts @@ -21,4 +21,5 @@ export enum FieldMetadataExceptionCode { FIELD_METADATA_RELATION_NOT_ENABLED = 'FIELD_METADATA_RELATION_NOT_ENABLED', FIELD_METADATA_RELATION_MALFORMED = 'FIELD_METADATA_RELATION_MALFORMED', LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND', + UNCOVERED_FIELD_METADATA_TYPE_VALIDATION = 'UNCOVERED_FIELD_METADATA_TYPE_VALIDATION', } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts index 43bcdca647..937f86a62f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service.ts @@ -1,8 +1,7 @@ -import { Injectable, ValidationError } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { t } from '@lingui/core/macro'; -import { plainToInstance } from 'class-transformer'; -import { IsEnum, IsString, IsUUID, validateOrReject } from 'class-validator'; +import { IsEnum, IsString, IsUUID } from 'class-validator'; import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { Repository } from 'typeorm'; @@ -20,6 +19,7 @@ import { } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { computeRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-relation-field-join-column-name.util'; import { prepareCustomFieldMetadataForCreation } from 'src/engine/metadata-modules/field-metadata/utils/prepare-field-metadata-for-creation.util'; +import { validateRelationCreationPayloadOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; @@ -156,19 +156,17 @@ export class FieldMetadataRelationService { .relationCreationPayload, ) ) { - validateFieldNameAvailabilityOrThrow( - `${fieldMetadataInput.name}Id`, + validateFieldNameAvailabilityOrThrow({ + name: `${fieldMetadataInput.name}Id`, objectMetadata, - ); + }); const relationCreationPayload = ( fieldMetadataInput as unknown as CreateFieldInput ).relationCreationPayload; if (isDefined(relationCreationPayload)) { - await this.validateRelationCreationPayloadOrThrow( - relationCreationPayload, - ); + await validateRelationCreationPayloadOrThrow(relationCreationPayload); const computedMetadataNameFromLabel = computeMetadataNameFromLabel( relationCreationPayload.targetFieldLabel, ); @@ -187,15 +185,15 @@ export class FieldMetadataRelationService { ); } - validateFieldNameAvailabilityOrThrow( - computedMetadataNameFromLabel, - objectMetadataTarget, - ); + validateFieldNameAvailabilityOrThrow({ + name: computedMetadataNameFromLabel, + objectMetadata: objectMetadataTarget, + }); - validateFieldNameAvailabilityOrThrow( - `${computedMetadataNameFromLabel}Id`, - objectMetadataTarget, - ); + validateFieldNameAvailabilityOrThrow({ + name: `${computedMetadataNameFromLabel}Id`, + objectMetadata: objectMetadataTarget, + }); if ( computedMetadataNameFromLabel === fieldMetadataInput.name && @@ -215,31 +213,6 @@ export class FieldMetadataRelationService { return fieldMetadataInput; } - private async validateRelationCreationPayloadOrThrow( - relationCreationPayload: RelationCreationPayloadValidation, - ) { - try { - const relationCreationPayloadInstance = plainToInstance( - RelationCreationPayloadValidation, - relationCreationPayload, - ); - - await validateOrReject(relationCreationPayloadInstance); - } catch (error) { - const errorMessages = Array.isArray(error) - ? error - .map((err: ValidationError) => Object.values(err.constraints ?? {})) - .flat() - .join(', ') - : error.message; - - throw new FieldMetadataException( - `Relation creation payload is invalid: ${errorMessages}`, - FieldMetadataExceptionCode.INVALID_FIELD_INPUT, - ); - } - } - async findCachedFieldMetadataRelation( fieldMetadataItems: Array< Pick< diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts index 2467bd79f0..c2c9a43944 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-validation.service.ts @@ -151,10 +151,10 @@ export class FieldMetadataValidationService { } try { - validateFieldNameAvailabilityOrThrow( - fieldMetadataInput.name, + validateFieldNameAvailabilityOrThrow({ + name: fieldMetadataInput.name, objectMetadata, - ); + }); } catch (error) { if (error instanceof InvalidMetadataException) { throw new FieldMetadataException( 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 new file mode 100644 index 0000000000..09721d3c90 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service-v2.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +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 { 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 { 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'; +import { fromObjectMetadataMapsToFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-maps-to-flat-object-metadatas.util'; +import { mergeTwoFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util'; +import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; +import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service'; +import { WorkspaceMigrationRunnerV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.service'; + +@Injectable() +export class FieldMetadataServiceV2 extends TypeOrmQueryService { + constructor( + @InjectRepository(FieldMetadataEntity, 'core') + private readonly fieldMetadataRepository: Repository, + private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, + private readonly workspaceMigrationBuilderV2: WorkspaceMigrationBuilderV2Service, + private readonly flatFieldMetadataValidatorService: FlatFieldMetadataValidatorService, + private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service, + ) { + super(fieldMetadataRepository); + } + + async createMany( + fieldMetadataInputs: CreateFieldInput[], + ): Promise { + if (!fieldMetadataInputs.length) { + return []; + } + + const workspaceId = fieldMetadataInputs[0].workspaceId; + + const { objectMetadataMaps } = + await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps( + { workspaceId }, + ); + + const existingFlatObjectMetadatas = + fromObjectMetadataMapsToFlatObjectMetadatas(objectMetadataMaps); + + let flatObjectMetadatasWithNewFields: FlatObjectMetadata[] = []; + + for (const fieldMetadataInput of fieldMetadataInputs) { + const createdFlatFieldsMetadataAndParentFlatObjectMetadata = + await fromCreateFieldInputToFlatFieldMetadata({ + existingFlatObjectMetadatas, + rawCreateFieldInput: fieldMetadataInput, + }); + + const createdFlatFieldMetadataValidationResult = ( + await Promise.all( + createdFlatFieldsMetadataAndParentFlatObjectMetadata.map( + ({ flatFieldMetadata: flatFieldMetadataToValidate }) => + this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadata( + { + existingFlatObjectMetadatas, + flatFieldMetadataToValidate, + workspaceId, + }, + ), + ), + ) + ).filter(isDefined); + + if (createdFlatFieldMetadataValidationResult.length > 0) { + const errors = createdFlatFieldMetadataValidationResult.map( + (validationResult) => validationResult.error, + ); + + throw new AggregateError( + errors, + 'Multiple validation errors occurred while creating field', + ); + } + + const updatedFlatObjectMetadatas = + createdFlatFieldsMetadataAndParentFlatObjectMetadata.map( + ({ flatFieldMetadata, parentFlatObjectMetadata }) => { + return { + ...parentFlatObjectMetadata, + flatFieldMetadatas: [ + ...parentFlatObjectMetadata.flatFieldMetadatas, + flatFieldMetadata, + ], + }; + }, + ); + + flatObjectMetadatasWithNewFields = mergeTwoFlatObjectMetadatas({ + destFlatObjectMetadatas: flatObjectMetadatasWithNewFields, + toMergeFlatObjectMetadatas: updatedFlatObjectMetadatas, + }); + } + + const workspaceMigration = this.workspaceMigrationBuilderV2.build({ + objectMetadataFromToInputs: { + from: existingFlatObjectMetadatas, + to: flatObjectMetadatasWithNewFields, + }, + inferDeletionFromMissingObjectFieldIndex: false, + workspaceId, + }); + + await this.workspaceMigrationRunnerV2Service.run(workspaceMigration); + + // const recomputedCache = + // await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps( + // { workspaceId }, + // ); + + return []; //TODO to retrieve from cache or directly from find + } +} 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 ba5e646f55..bfc2adc69b 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 @@ -223,10 +223,10 @@ export class FieldMetadataService extends TypeOrmQueryService { case FieldMetadataExceptionCode.INTERNAL_SERVER_ERROR: case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_NOT_ENABLED: case FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED: + case FieldMetadataExceptionCode.UNCOVERED_FIELD_METADATA_TYPE_VALIDATION: case FieldMetadataExceptionCode.LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND: throw error; default: { diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload.util.ts new file mode 100644 index 0000000000..710c5a14b4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload.util.ts @@ -0,0 +1,33 @@ +import { plainToInstance } from 'class-transformer'; +import { validateOrReject, ValidationError } from 'class-validator'; + +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { RelationCreationPayloadValidation } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-relation.service'; + +export const validateRelationCreationPayloadOrThrow = async ( + relationCreationPayload: RelationCreationPayloadValidation, +) => { + try { + const relationCreationPayloadInstance = plainToInstance( + RelationCreationPayloadValidation, + relationCreationPayload, + ); + + await validateOrReject(relationCreationPayloadInstance); + } catch (error) { + const errorMessages = Array.isArray(error) + ? error + .map((err: ValidationError) => Object.values(err.constraints ?? {})) + .flat() + .join(', ') + : error.message; + + throw new FieldMetadataException( + `Relation creation payload is invalid: ${errorMessages}`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts index 3785134d4d..08f8dafba6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts @@ -13,13 +13,10 @@ type FlatFieldMetadataOverrides< export const getFlatFieldMetadataMock = ( overrides: FlatFieldMetadataOverrides, ): FlatFieldMetadata => { - const createdAt = faker.date.anytime(); - return { defaultValue: null, options: null, settings: null, - createdAt, description: 'default flat field metadata description', icon: 'icon', id: faker.string.uuid(), @@ -33,7 +30,6 @@ export const getFlatFieldMetadataMock = ( isSystem: false, standardId: null, standardOverrides: null, - updatedAt: createdAt, workspaceId: faker.string.uuid(), flatRelationTargetFieldMetadata: null, relationTargetFieldMetadataId: null, 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 new file mode 100644 index 0000000000..a2bb1faf02 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service.ts @@ -0,0 +1,183 @@ +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 { 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'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { + InvalidMetadataException, + InvalidMetadataExceptionCode, +} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; +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 = { + existingFlatObjectMetadatas: FlatObjectMetadata[]; + othersFlatObjectMetadataToValidate?: FlatObjectMetadata[]; + flatFieldMetadataToValidate: FlatFieldMetadata; + workspaceId: string; +}; + +@Injectable() +export class FlatFieldMetadataValidatorService { + constructor(private readonly featureFlagService: FeatureFlagService) {} + + async validateOneFlatFieldMetadata({ + existingFlatObjectMetadatas, + flatFieldMetadataToValidate, + othersFlatObjectMetadataToValidate, + workspaceId, + }: ValidateOneFieldMetadataArgs): Promise< + FailedFlatFieldMetadataValidation | undefined + > { + const allFlatObjectMetadata = [ + ...existingFlatObjectMetadatas, + ...(othersFlatObjectMetadataToValidate ?? []), + ]; + const parentFlatObjectMetadata = allFlatObjectMetadata.find( + (existingFlatObjectMetadata) => + existingFlatObjectMetadata.id === + flatFieldMetadataToValidate.objectMetadataId, // Question: Should we comparing unique identifier here ? + ); + + if (!isDefined(parentFlatObjectMetadata)) { + return { + status: 'fail', + error: new FieldMetadataException( + isDefined(othersFlatObjectMetadataToValidate) + ? 'Object metadata does not exist in both existing and about to be created object metadatas' + : 'Object metadata does not exist', + FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + ), + }; + } + + if (parentFlatObjectMetadata.isRemote === true) { + return { + status: 'fail', + error: new ObjectMetadataException( + 'Remote objects are read-only', + ObjectMetadataExceptionCode.OBJECT_MUTATION_NOT_ALLOWED, + ), + }; + } + + if (flatFieldMetadataToValidate.isLabelSyncedWithName) { + const computedName = computeMetadataNameFromLabel( + flatFieldMetadataToValidate.label, + ); + + if (flatFieldMetadataToValidate.name !== computedName) { + return { + status: 'fail', + error: 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( + error.message, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + { + userFriendlyMessage: error.userFriendlyMessage, + }, + ), + }; + } + + const failedNameAvailabilityValidation = + validateFlatFieldMetadataNameAvailability({ + name: flatFieldMetadataToValidate.name, + objectMetadata: parentFlatObjectMetadata, + }); + + if (isDefined(failedNameAvailabilityValidation)) { + return failedNameAvailabilityValidation; + } + + // 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, + ), + }; + } + } + } +} 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 new file mode 100644 index 0000000000..758342769e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type.ts @@ -0,0 +1,11 @@ +import { FieldMetadataException } 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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts index a920cf727e..df51aee648 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type.ts @@ -16,7 +16,10 @@ export type FieldMetadataEntityRelationProperties = (typeof fieldMetadataRelationProperties)[number]; export type FlatFieldMetadata = - Omit, FieldMetadataEntityRelationProperties> & { + Omit< + FieldMetadataEntity, + FieldMetadataEntityRelationProperties | 'createdAt' | 'updatedAt' + > & { uniqueIdentifier: string; flatRelationTargetFieldMetadata: AssignTypeIfIsRelationFieldMetadataType< Omit< diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util.ts new file mode 100644 index 0000000000..ab2389746e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util.ts @@ -0,0 +1,158 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { + assertUnreachable, + isDefined, + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, +} from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { FieldMetadataOptions } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-options.interface'; + +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { generateRatingOptions } from 'src/engine/metadata-modules/field-metadata/utils/generate-rating-optionts.util'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { fromRelationCreateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadata.util'; +import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util'; +import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +type FromCreateFieldInputToFlatObjectMetadata = { + rawCreateFieldInput: CreateFieldInput; + existingFlatObjectMetadatas: FlatObjectMetadata[]; +}; +export type FlatFieldMetadataAndParentFlatObjectMetadata< + T extends FieldMetadataType = FieldMetadataType, +> = { + flatFieldMetadata: FlatFieldMetadata; + parentFlatObjectMetadata: FlatObjectMetadata; +}; + +export const fromCreateFieldInputToFlatFieldMetadata = async ({ + existingFlatObjectMetadatas, + rawCreateFieldInput, +}: FromCreateFieldInputToFlatObjectMetadata): Promise< + FlatFieldMetadataAndParentFlatObjectMetadata[] +> => { + if (rawCreateFieldInput.isRemoteCreation) { + throw new FieldMetadataException( + "Remote fields aren't supported", + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + const createFieldInput = + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + rawCreateFieldInput, + ['description', 'icon', 'label', 'name', 'objectMetadataId', 'type'], + ); + const parentFlatObjectMetadata = existingFlatObjectMetadatas.find( + (existingFlatObjectMetadata) => + existingFlatObjectMetadata.id === createFieldInput.objectMetadataId, + ); + + if (!isDefined(parentFlatObjectMetadata)) { + throw new FieldMetadataException( + 'Provided object metadata id does not exist', + FieldMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND, + { + userFriendlyMessage: + 'Created field metadata, parent object metadata not found', + }, + ); + } + + const fieldMetadataId = v4(); + const commonFlatFieldMetadata = getDefaultFlatFieldMetadata({ + createFieldInput, + fieldMetadataId, + }); + + switch (createFieldInput.type) { + case FieldMetadataType.MORPH_RELATION: { + throw new UserInputError( + 'Morph relation feature is not migrated to workspace migration v2 yet', + ); + } + case FieldMetadataType.RELATION: { + return fromRelationCreateFieldInputToFlatFieldMetadata({ + existingFlatObjectMetadatas, + sourceParentFlatObjectMetadata: parentFlatObjectMetadata, + createFieldInput, + }); + } + case FieldMetadataType.RATING: { + return [ + { + flatFieldMetadata: { + ...commonFlatFieldMetadata, + type: createFieldInput.type, + settings: null, + defaultValue: commonFlatFieldMetadata.defaultValue as string, // Could this be improved ? + options: generateRatingOptions(), + } satisfies FlatFieldMetadata, + parentFlatObjectMetadata, + }, + ]; + } + case FieldMetadataType.SELECT: + case FieldMetadataType.MULTI_SELECT: { + const options = (createFieldInput?.options ?? []).map< + FieldMetadataOptions[number] + >((option) => ({ + ...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + option as FieldMetadataOptions[number], + ['label', 'value', 'id', 'color'], + ), + })); + + return [ + { + flatFieldMetadata: { + ...commonFlatFieldMetadata, + type: createFieldInput.type, + options, + defaultValue: commonFlatFieldMetadata.defaultValue as string, // Could this be improved ? + settings: null, + } satisfies FlatFieldMetadata, + parentFlatObjectMetadata, + }, + ]; + } + 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.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 [ + { + flatFieldMetadata: { + ...commonFlatFieldMetadata, + type: createFieldInput.type, + }, + parentFlatObjectMetadata, + }, + ]; + } + default: { + assertUnreachable(createFieldInput.type, 'Encountered an uncovered'); + } + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts index 56e4966716..b3c322c72d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts @@ -3,6 +3,7 @@ import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { + FieldMetadataEntityRelationProperties, FlatFieldMetadata, fieldMetadataRelationProperties, } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -26,11 +27,6 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = < ); } - const fieldMetadataWithoutRelations = removePropertiesFromRecord( - fieldMetadataEntity, - fieldMetadataRelationProperties, - ); - if ( isFieldMetadataEntityOfType( fieldMetadataEntity, @@ -41,12 +37,20 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = < FieldMetadataType.MORPH_RELATION, ) ) { + const fieldMetadataWithoutRelations = removePropertiesFromRecord< + FieldMetadataEntity< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, + FieldMetadataEntityRelationProperties + >(fieldMetadataEntity, fieldMetadataRelationProperties); + const newDepth = isDefined(_depth) ? _depth + 1 : 1; const flatRelationTargetFieldMetadata = fromFieldMetadataEntityToFlatFieldMetadata( fieldMetadataEntity.relationTargetFieldMetadata, newDepth, ); + const flatObjectTargetFieldMetadata = fromObjectMetadataEntityToFlatObjectMetadata( fieldMetadataEntity.relationTargetObjectMetadata, @@ -63,9 +67,17 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = < fieldMetadataWithoutRelations.id, flatRelationTargetFieldMetadata, flatRelationTargetObjectMetadata, - } as FlatFieldMetadata; + type: fieldMetadataEntity.type, + } satisfies FlatFieldMetadata< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >; } + const fieldMetadataWithoutRelations = removePropertiesFromRecord( + fieldMetadataEntity, + fieldMetadataRelationProperties, + ); + return { ...fieldMetadataWithoutRelations, uniqueIdentifier: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadata.util.ts new file mode 100644 index 0000000000..b3927e53ab --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-relation-create-field-input-to-flat-field-metadata.util.ts @@ -0,0 +1,108 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; +import { + FieldMetadataException, + FieldMetadataExceptionCode, +} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; +import { validateRelationCreationPayloadOrThrow } from 'src/engine/metadata-modules/field-metadata/utils/validate-relation-creation-payload.util'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { FlatFieldMetadataAndParentFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-create-field-input-to-flat-field-metadata.util'; +import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util'; +import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util'; + +type FromRelationCreateFieldInputToFlatFieldMetadataArgs = { + createFieldInput: CreateFieldInput; + existingFlatObjectMetadatas: FlatObjectMetadata[]; + sourceParentFlatObjectMetadata: FlatObjectMetadata; +}; +export const fromRelationCreateFieldInputToFlatFieldMetadata = async ({ + existingFlatObjectMetadatas, + sourceParentFlatObjectMetadata, + createFieldInput, +}: FromRelationCreateFieldInputToFlatFieldMetadataArgs): Promise< + FlatFieldMetadataAndParentFlatObjectMetadata[] +> => { + const { relationCreationPayload } = createFieldInput; + + if (!isDefined(relationCreationPayload)) { + throw new FieldMetadataException( + `Relation creation payload is required`, + FieldMetadataExceptionCode.INVALID_FIELD_INPUT, + ); + } + await validateRelationCreationPayloadOrThrow(relationCreationPayload); + + const targetParentFlatObjectMetadata = existingFlatObjectMetadatas.find( + (existingFlatObject) => + existingFlatObject.id === relationCreationPayload.targetObjectMetadataId, + ); + + if (!isDefined(targetParentFlatObjectMetadata)) { + throw new FieldMetadataException( + `Object metadata relation target not found for relation creation payload`, + FieldMetadataExceptionCode.FIELD_METADATA_RELATION_MALFORMED, + ); + } + + const targetRelationTargetFieldMetadataId = v4(); + const sourceRelationTargetFieldMetadataId = v4(); + const sourceFlatFieldMetadata: Omit< + FlatFieldMetadata, + 'flatRelationTargetFieldMetadata' + > = { + ...getDefaultFlatFieldMetadata({ + createFieldInput, + fieldMetadataId: sourceRelationTargetFieldMetadataId, + }), + type: FieldMetadataType.RELATION, + defaultValue: null, + settings: null, + options: null, + relationTargetFieldMetadataId: targetRelationTargetFieldMetadataId, // Note: this won't work until we enable deferred transaction + relationTargetObjectMetadataId: targetParentFlatObjectMetadata.id, + flatRelationTargetObjectMetadata: targetParentFlatObjectMetadata, + }; + + const targetFlatFieldMetadata: FlatFieldMetadata = + { + ...getDefaultFlatFieldMetadata({ + createFieldInput: { + icon: relationCreationPayload.targetFieldIcon, + label: relationCreationPayload.targetFieldLabel, + name: `${computeMetadataNameFromLabel( + relationCreationPayload.targetFieldLabel, + )}Id`, + objectMetadataId: targetParentFlatObjectMetadata.id, + type: FieldMetadataType.RELATION, + workspaceId: createFieldInput.workspaceId, + }, + fieldMetadataId: targetRelationTargetFieldMetadataId, + }), + type: FieldMetadataType.RELATION, + defaultValue: null, + settings: null, + options: null, + relationTargetFieldMetadataId: sourceRelationTargetFieldMetadataId, + relationTargetObjectMetadataId: sourceParentFlatObjectMetadata.id, + flatRelationTargetFieldMetadata: sourceFlatFieldMetadata, + flatRelationTargetObjectMetadata: sourceParentFlatObjectMetadata, + }; + + return [ + { + flatFieldMetadata: { + ...sourceFlatFieldMetadata, + flatRelationTargetFieldMetadata: targetFlatFieldMetadata, + }, + parentFlatObjectMetadata: sourceParentFlatObjectMetadata, + }, + { + flatFieldMetadata: targetFlatFieldMetadata, + parentFlatObjectMetadata: targetParentFlatObjectMetadata, + }, + ] satisfies FlatFieldMetadataAndParentFlatObjectMetadata[]; +}; 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 new file mode 100644 index 0000000000..f65e8dbc90 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -0,0 +1,50 @@ +import { sanitizeObjectStringFields } 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'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +type GetDefaultFlatFieldMetadataArgs = { + fieldMetadataId: string; + createFieldInput: CreateFieldInput; +}; +export const getDefaultFlatFieldMetadata = ({ + createFieldInput, + fieldMetadataId, +}: GetDefaultFlatFieldMetadataArgs) => { + const { defaultValue, settings } = sanitizeObjectStringFields( + createFieldInput, + ['defaultValue', 'settings'], + ); + + return { + description: createFieldInput.description ?? null, + id: fieldMetadataId, + icon: createFieldInput.icon ?? null, + isActive: true, + isCustom: true, + isLabelSyncedWithName: createFieldInput.isLabelSyncedWithName ?? false, + isNullable: generateNullable( + createFieldInput.type, + createFieldInput.isNullable, + createFieldInput.isRemoteCreation, + ), + isSystem: false, + isUnique: createFieldInput.isUnique ?? null, + label: createFieldInput.label ?? null, + name: createFieldInput.name ?? null, + objectMetadataId: createFieldInput.objectMetadataId, + relationTargetFieldMetadataId: null, + relationTargetObjectMetadataId: null, + standardId: null, + standardOverrides: null, + type: createFieldInput.type, + uniqueIdentifier: fieldMetadataId, + workspaceId: createFieldInput.workspaceId, + flatRelationTargetFieldMetadata: null, + flatRelationTargetObjectMetadata: null, + options: null, + defaultValue: defaultValue ?? null, + settings: settings ?? null, + } as const satisfies FlatFieldMetadata; +}; 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 new file mode 100644 index 0000000000..8bd4f88ac9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator.ts @@ -0,0 +1,82 @@ +import { t } from '@lingui/core/macro'; +import { FieldMetadataType } from 'twenty-shared/types'; +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 { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { + InvalidMetadataException, + InvalidMetadataExceptionCode, +} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; + +const getReservedCompositeFieldNames = ( + flatObjectMetadata: FlatObjectMetadata, +): string[] => { + return flatObjectMetadata.flatFieldMetadatas.flatMap((flatFieldMetadata) => { + if (isCompositeFieldMetadataType(flatFieldMetadata.type)) { + const base = flatFieldMetadata.name; + const compositeType = compositeTypeDefinitions.get( + flatFieldMetadata.type, + ); + + if (!isDefined(compositeType)) { + return []; + } + + return compositeType.properties.map((property) => + computeCompositeColumnName(base, property), + ); + } + + return []; + }); +}; + +export const validateFlatFieldMetadataNameAvailability = ({ + name, + objectMetadata, +}: { + name: string; + objectMetadata: FlatObjectMetadata; +}): FailedFlatFieldMetadataValidation | undefined => { + const reservedCompositeFieldsNames = + getReservedCompositeFieldNames(objectMetadata); + + if ( + objectMetadata.flatFieldMetadatas.some( + (field) => + field.name === name || + (field.type === FieldMetadataType.RELATION && // Question: Should we also look for MORPH_RELATION field types ? + `${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`, + }, + ), + }; + } + + 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 undefined; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts index f07a57517d..876c952b1c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts @@ -9,15 +9,10 @@ type FlatObjectMetadataOverrides = Required< export const getFlatObjectMetadataMock = ( overrides: FlatObjectMetadataOverrides, ): FlatObjectMetadata => { - const createdAt = faker.date.anytime(); - return { flatFieldMetadatas: [], flatIndexMetadatas: [], - createdAt, - dataSourceId: faker.string.uuid(), description: 'default flat object metadata description', - duplicateCriteria: [], icon: 'icon', id: faker.string.uuid(), imageIdentifierFieldMetadataId: faker.string.uuid(), @@ -37,7 +32,6 @@ export const getFlatObjectMetadataMock = ( standardId: null, standardOverrides: null, targetTableName: '', - updatedAt: createdAt, workspaceId: faker.string.uuid(), ...overrides, }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type.ts index 65282fdbfa..18d9c0a207 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type.ts @@ -20,7 +20,11 @@ type ObjectMetadataRelationProperties = ExtractRecordTypeOrmRelationProperties< export type FlatObjectMetadata = Omit< ObjectMetadataEntity, - ObjectMetadataRelationProperties + | ObjectMetadataRelationProperties + | 'dataSourceId' + | 'createdAt' + | 'updatedAt' + | 'duplicateCriteria' > & { uniqueIdentifier: string; flatIndexMetadatas: FlatIndexMetadata[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/compare-two-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/compare-two-flat-object-metadata.util.ts index 1619afe8c9..be4aafb4a4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/compare-two-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/compare-two-flat-object-metadata.util.ts @@ -23,7 +23,7 @@ export type FlatObjectMetadataPropertiesToCompare = (typeof flatObjectMetadataPropertiesToCompare)[number]; /** - * This comparator handles update on colliding uniqueIdentifier flatObjectdMetadata + * This comparator handles update on colliding uniqueIdentifier flatObjectMetadata */ export const compareTwoFlatObjectMetadata = ({ from, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util.ts new file mode 100644 index 0000000000..47cd65269c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util.ts @@ -0,0 +1,69 @@ +import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; +import { + ObjectMetadataException, + ObjectMetadataExceptionCode, +} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; +import { buildDefaultFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util'; + +export const fromCreateObjectInputToFlatObjectMetadata = ( + rawCreateObjectInput: CreateObjectInput, +): FlatObjectMetadata => { + if (rawCreateObjectInput.isRemote) { + throw new ObjectMetadataException( + 'Remote objects are not supported', + ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, + ); + } + + const createObjectInput = + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + rawCreateObjectInput, + [ + 'description', + 'icon', + 'labelPlural', + 'labelSingular', + 'namePlural', + 'nameSingular', + 'shortcut', + ], + ); + + const objectMetadataId = v4(); + const baseCustomFlatFieldMetadatas = + buildDefaultFlatFieldMetadataForCustomObject({ + objectMetadataId, + workspaceId: createObjectInput.workspaceId, + }); + + return { + description: createObjectInput.description ?? null, + flatFieldMetadatas: Object.values(baseCustomFlatFieldMetadatas), + flatIndexMetadatas: [], + icon: createObjectInput.icon ?? null, + id: objectMetadataId, + imageIdentifierFieldMetadataId: null, + isActive: true, + isAuditLogged: true, + isCustom: true, + isLabelSyncedWithName: createObjectInput.isLabelSyncedWithName ?? false, + isRemote: false, + isSearchable: true, + isSystem: false, + labelIdentifierFieldMetadataId: baseCustomFlatFieldMetadatas.nameField.id, + labelPlural: createObjectInput.labelPlural ?? null, + labelSingular: createObjectInput.labelSingular ?? null, + namePlural: createObjectInput.namePlural ?? null, + nameSingular: createObjectInput.nameSingular ?? null, + shortcut: createObjectInput.shortcut ?? null, + standardId: null, + standardOverrides: null, + uniqueIdentifier: objectMetadataId, + targetTableName: 'DEPRECATED', + workspaceId: createObjectInput.workspaceId, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util.ts index 747c903267..64ec8b38c1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util.ts @@ -8,7 +8,7 @@ import { mergeTwoFlatFieldMetadatas } from 'src/engine/metadata-modules/flat-fie import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { ToMerge } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/to-merge.type'; -export const mergeTwoFlatFieldObjectMetadatas = ({ +export const mergeTwoFlatObjectMetadatas = ({ destFlatObjectMetadatas, toMergeFlatObjectMetadatas, }: ToMerge): FlatObjectMetadata[] => { diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/create-object.input.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/create-object.input.ts index dc78198b90..7d0314e8d7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/create-object.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/dtos/create-object.input.ts @@ -4,16 +4,11 @@ import { BeforeCreateOne } from '@ptc-org/nestjs-query-graphql'; import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import GraphQLJSON from 'graphql-type-json'; import { FieldMetadataType } from 'twenty-shared/types'; -import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface'; -import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator'; -import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { BeforeCreateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-create-one-object.hook'; -import { buildDefaultFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util'; @InputType() @BeforeCreateOne(BeforeCreateOneObject) @@ -79,65 +74,3 @@ export class CreateObjectInput { @Field({ nullable: true }) // Not nullable to me isLabelSyncedWithName?: boolean; } - -export const fromCreateObjectInputToFlatObjectMetadata = ( - rawCreateObjectInput: CreateObjectInput, -): FlatObjectMetadata => { - if (rawCreateObjectInput.isRemote) { - throw new UserInputError('Remote objects are not supported yet'); - } - - const createObjectInput = - trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( - rawCreateObjectInput, - [ - 'description', - 'icon', - 'labelPlural', - 'labelSingular', - 'namePlural', - 'nameSingular', - 'shortcut', - ], - ); - - const objectMetadataId = v4(); - const createdAt = new Date(); - const baseCustomFlatFieldMetadatas = - buildDefaultFlatFieldMetadataForCustomObject({ - createdAt, - objectMetadataId, - workspaceId: createObjectInput.workspaceId, - }); - - return { - createdAt, - updatedAt: createdAt, - dataSourceId: createObjectInput.dataSourceId, // TODO is it enough ? - description: createObjectInput.description ?? null, - duplicateCriteria: [], // TODO is it enough ? - flatFieldMetadatas: Object.values(baseCustomFlatFieldMetadatas), - flatIndexMetadatas: [], // TODO is it enough ? - icon: createObjectInput.icon ?? null, - id: objectMetadataId, - imageIdentifierFieldMetadataId: null, - isActive: true, - isAuditLogged: true, - isCustom: true, - isLabelSyncedWithName: createObjectInput.isLabelSyncedWithName ?? false, - isRemote: false, - isSearchable: true, - isSystem: false, - labelIdentifierFieldMetadataId: baseCustomFlatFieldMetadatas.nameField.id, - labelPlural: createObjectInput.labelPlural ?? null, - labelSingular: createObjectInput.labelSingular ?? null, - namePlural: createObjectInput.namePlural ?? null, - nameSingular: createObjectInput.nameSingular ?? null, - shortcut: createObjectInput.shortcut ?? null, - standardId: null, - standardOverrides: null, - uniqueIdentifier: objectMetadataId, - targetTableName: 'DEPRECATED', - workspaceId: createObjectInput.workspaceId, - }; -}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts new file mode 100644 index 0000000000..990c5861f7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata-v2.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; +import { Repository } from 'typeorm'; + +import { fromCreateObjectInputToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util'; +import { fromObjectMetadataMapsToFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-maps-to-flat-object-metadatas.util'; +import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service'; +import { WorkspaceMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service'; +import { WorkspaceMigrationRunnerV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.service'; + +import { ObjectMetadataEntity } from './object-metadata.entity'; + +import { CreateObjectInput } from './dtos/create-object.input'; + +@Injectable() +export class ObjectMetadataServiceV2 extends TypeOrmQueryService { + constructor( + @InjectRepository(ObjectMetadataEntity, 'core') + private readonly objectMetadataRepository: Repository, + + private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService, + private readonly workspaceMigrationBuilderV2: WorkspaceMigrationBuilderV2Service, + private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service, + ) { + super(objectMetadataRepository); + } + + override async createOne( + objectMetadataInput: CreateObjectInput, + ): Promise { + const { objectMetadataMaps } = + await this.workspaceMetadataCacheService.getExistingOrRecomputeMetadataMaps( + { + workspaceId: objectMetadataInput.workspaceId, + }, + ); + + const createdRawFlatObjectMetadata = + fromCreateObjectInputToFlatObjectMetadata(objectMetadataInput); + const existingFlatObjectMetadatas = + fromObjectMetadataMapsToFlatObjectMetadatas(objectMetadataMaps); + // @ts-expect-error TODO implement validateFlatObjectMetadataData + const createdFlatObjectMetadata = validateFlatObjectMetadataData({ + existing: + // Here we assume that EVERYTHING is in cache and up to date, this is very critical, also race condition prone :thinking: + fromObjectMetadataMapsToFlatObjectMetadatas(objectMetadataMaps), + toValidate: [createdRawFlatObjectMetadata], + }); + + const workspaceMigration = this.workspaceMigrationBuilderV2.build({ + objectMetadataFromToInputs: { + from: existingFlatObjectMetadatas, + to: [createdFlatObjectMetadata], + }, + inferDeletionFromMissingObjectFieldIndex: false, + workspaceId: objectMetadataInput.workspaceId, + }); + + await this.workspaceMigrationRunnerV2Service.run(workspaceMigration); + + return createdFlatObjectMetadata; // TODO retrieve from cache + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 43d7894961..4142dbae82 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -17,8 +17,8 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { fromCreateObjectInputToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util'; import { fromObjectMetadataMapsToFlatObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-maps-to-flat-object-metadatas.util'; -import { mergeTwoFlatFieldObjectMetadatas } from 'src/engine/metadata-modules/flat-object-metadata/utils/merge-two-flat-object-metadatas.util'; import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input'; import { @@ -57,10 +57,7 @@ import { isSearchableFieldType } from 'src/engine/workspace-manager/workspace-sy import { ObjectMetadataEntity } from './object-metadata.entity'; -import { - CreateObjectInput, - fromCreateObjectInputToFlatObjectMetadata, -} from './dtos/create-object.input'; +import { CreateObjectInput } from './dtos/create-object.input'; @Injectable() export class ObjectMetadataService extends TypeOrmQueryService { @@ -147,18 +144,16 @@ export class ObjectMetadataService extends TypeOrmQueryService { const idField: FlatFieldMetadata = { type: FieldMetadataType.UUID, - createdAt, id: v4(), - isLabelSyncedWithName: false, // TO CHECK - isUnique: true, // Was false before but unsure if normal ? + isLabelSyncedWithName: false, + isUnique: true, objectMetadataId, uniqueIdentifier: BASE_OBJECT_STANDARD_FIELD_IDS.id, - updatedAt: createdAt, workspaceId, standardId: BASE_OBJECT_STANDARD_FIELD_IDS.id, name: 'id', @@ -159,13 +155,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const nameField: FlatFieldMetadata = { type: FieldMetadataType.TEXT, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name, - updatedAt: createdAt, workspaceId, standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name, name: 'name', @@ -189,13 +183,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const createdAtField: FlatFieldMetadata = { type: FieldMetadataType.DATE_TIME, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, - updatedAt: createdAt, workspaceId, standardId: BASE_OBJECT_STANDARD_FIELD_IDS.createdAt, name: 'createdAt', @@ -219,13 +211,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const updatedAtField: FlatFieldMetadata = { type: FieldMetadataType.DATE_TIME, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, - updatedAt: createdAt, workspaceId, standardId: BASE_OBJECT_STANDARD_FIELD_IDS.updatedAt, name: 'updatedAt', @@ -249,13 +239,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const deletedAtField: FlatFieldMetadata = { type: FieldMetadataType.DATE_TIME, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: BASE_OBJECT_STANDARD_FIELD_IDS.deletedAt, - updatedAt: createdAt, workspaceId, standardId: BASE_OBJECT_STANDARD_FIELD_IDS.deletedAt, name: 'deletedAt', @@ -279,13 +267,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const createdByField: FlatFieldMetadata = { type: FieldMetadataType.ACTOR, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: CUSTOM_OBJECT_STANDARD_FIELD_IDS.createdBy, - updatedAt: createdAt, workspaceId, standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.createdBy, name: 'createdBy', @@ -309,13 +295,11 @@ export const buildDefaultFlatFieldMetadataForCustomObject = ({ const positionField: FlatFieldMetadata = { type: FieldMetadataType.POSITION, - createdAt, id: v4(), isLabelSyncedWithName: false, isUnique: false, objectMetadataId, uniqueIdentifier: CUSTOM_OBJECT_STANDARD_FIELD_IDS.position, - updatedAt: createdAt, workspaceId, standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.position, name: 'position', diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-field-name-availability.spec.ts b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-field-name-availability.spec.ts index 50616fe8e8..7fca4ff519 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-field-name-availability.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/__tests__/validate-field-name-availability.spec.ts @@ -62,17 +62,17 @@ describe('validateFieldNameAvailabilityOrThrow', () => { ({ context: { input, shouldNotThrow } }) => { if (shouldNotThrow) { expect(() => - validateFieldNameAvailabilityOrThrow( - input, - objectMetadataMapItemMock, - ), + validateFieldNameAvailabilityOrThrow({ + name: input, + objectMetadata: objectMetadataMapItemMock, + }), ).not.toThrow(); } else { expect(() => - validateFieldNameAvailabilityOrThrow( - input, - objectMetadataMapItemMock, - ), + validateFieldNameAvailabilityOrThrow({ + name: input, + objectMetadata: objectMetadataMapItemMock, + }), ).toThrowErrorMatchingSnapshot(); } }, diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts index 062507a734..72b8e5302d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-field-name-availability.utils.ts @@ -31,10 +31,14 @@ const getReservedCompositeFieldNames = ( return reservedCompositeFieldsNames; }; -export const validateFieldNameAvailabilityOrThrow = ( - name: string, - objectMetadata: ObjectMetadataItemWithFieldMaps, -) => { +type ValidateFieldNameAvailabilityOrThrowArgs = { + name: string; + objectMetadata: ObjectMetadataItemWithFieldMaps; +}; +export const validateFieldNameAvailabilityOrThrow = ({ + name, + objectMetadata, +}: ValidateFieldNameAvailabilityOrThrowArgs) => { const reservedCompositeFieldsNames = getReservedCompositeFieldNames(objectMetadata); diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts index 6f7968d89e..6877adb576 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util.ts @@ -7,10 +7,13 @@ import { InvalidMetadataExceptionCode, } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception'; -export const validateNameAndLabelAreSyncOrThrow = ( - label: string, - name: string, -) => { +export const validateNameAndLabelAreSyncOrThrow = ({ + label, + name, +}: { + label: string; + name: string; +}) => { const computedName = computeMetadataNameFromLabel(label); if (name !== computedName) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/__tests__/__snapshots__/workspace-migration-builder.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/__tests__/__snapshots__/workspace-migration-builder.spec.ts.snap index c3301fa78b..c302e40863 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/__tests__/__snapshots__/workspace-migration-builder.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/__tests__/__snapshots__/workspace-migration-builder.spec.ts.snap @@ -6,7 +6,6 @@ exports[`Workspace migration builder field actions test suite It should build a [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -30,14 +29,10 @@ exports[`Workspace migration builder field actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -58,7 +53,6 @@ exports[`Workspace migration builder field actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "type": "delete_field", @@ -70,7 +64,6 @@ exports[`Workspace migration builder field actions test suite It should build an [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -94,14 +87,10 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -122,7 +111,6 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", @@ -134,11 +122,9 @@ exports[`Workspace migration builder field actions test suite It should build an [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -162,14 +148,10 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "type": "RELATION", "uniqueIdentifier": "field-metadata-unique-identifier-2", - "updatedAt": Any, "workspaceId": Any, }, "flatRelationTargetObjectMetadata": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "flatFieldMetadatas": [], "flatIndexMetadatas": [], "icon": "icon", @@ -192,7 +174,6 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-2", - "updatedAt": Any, "workspaceId": Any, }, "icon": "icon", @@ -214,14 +195,10 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "type": "RELATION", "uniqueIdentifier": "field-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -242,7 +219,6 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", @@ -254,7 +230,6 @@ exports[`Workspace migration builder field actions test suite It should build an [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "new description", "flatRelationTargetFieldMetadata": null, @@ -278,14 +253,10 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -306,7 +277,6 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "type": "update_field", @@ -341,7 +311,6 @@ exports[`Workspace migration builder field actions test suite It should build an [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "new description", "flatRelationTargetFieldMetadata": null, @@ -365,14 +334,10 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "type": "RELATION", "uniqueIdentifier": "field-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -393,7 +358,6 @@ exports[`Workspace migration builder field actions test suite It should build an "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "object-metadata-unique-identifier-1", - "updatedAt": Any, "workspaceId": Any, }, "type": "update_field", @@ -511,10 +475,7 @@ exports[`Workspace migration builder object actions test suite It should build a { "createFieldActions": [], "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -535,7 +496,6 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_object", @@ -549,7 +509,6 @@ exports[`Workspace migration builder object actions test suite It should build a "createFieldActions": [ { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -573,14 +532,10 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field_0", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -601,14 +556,12 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", }, { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -632,14 +585,10 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field_1", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -660,14 +609,12 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", }, { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -691,14 +638,10 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field_2", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -719,14 +662,12 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", }, { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -750,14 +691,10 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field_3", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -778,14 +715,12 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", }, { "flatFieldMetadata": { - "createdAt": Any, "defaultValue": null, "description": "default flat field metadata description", "flatRelationTargetFieldMetadata": null, @@ -809,14 +744,10 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "type": "TEXT", "uniqueIdentifier": "field_4", - "updatedAt": Any, "workspaceId": Any, }, "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -837,17 +768,13 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_field", }, ], "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -868,7 +795,6 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "create_object", @@ -897,10 +823,7 @@ exports[`Workspace migration builder object actions test suite It should build a [ { "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -921,7 +844,6 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "delete_object", @@ -933,10 +855,7 @@ exports[`Workspace migration builder object actions test suite It should build a [ { "flatObjectMetadataWithoutFields": { - "createdAt": Any, - "dataSourceId": Any, "description": "default flat object metadata description", - "duplicateCriteria": [], "icon": "icon", "id": Any, "imageIdentifierFieldMetadataId": Any, @@ -957,7 +876,6 @@ exports[`Workspace migration builder object actions test suite It should build a "standardOverrides": null, "targetTableName": "", "uniqueIdentifier": "pomme", - "updatedAt": Any, "workspaceId": Any, }, "type": "update_object", diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service.ts index b3ea213d6b..1183155770 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.service.ts @@ -19,9 +19,11 @@ export class WorkspaceMigrationBuilderV2Service { build({ objectMetadataFromToInputs, workspaceId, + inferDeletionFromMissingObjectFieldIndex = true, }: { objectMetadataFromToInputs: FromTo; workspaceId: string; + inferDeletionFromMissingObjectFieldIndex?: boolean; }): WorkspaceMigrationV2 { const { created: createdObjectMetadata, @@ -44,32 +46,36 @@ export class WorkspaceMigrationBuilderV2Service { ); const deletedObjectWorkspaceMigrationDeleteFieldActions = - deletedObjectMetadata.flatMap((flatObjectMetadata) => - flatObjectMetadata.flatFieldMetadatas.map((flatFieldMetadata) => - getWorkspaceMigrationV2FieldDeleteAction({ - flatFieldMetadata, - flatObjectMetadata, - }), - ), - ); + inferDeletionFromMissingObjectFieldIndex + ? deletedObjectMetadata.flatMap((flatObjectMetadata) => + flatObjectMetadata.flatFieldMetadatas.map((flatFieldMetadata) => + getWorkspaceMigrationV2FieldDeleteAction({ + flatFieldMetadata, + flatObjectMetadata, + }), + ), + ) + : []; - const updatedObjectMetadataDeletedCreatedUpdatedFieldMatrix = + const objectMetadataDeletedCreatedUpdatedFields = computeUpdatedObjectMetadataDeletedCreatedUpdatedFieldMatrix( updatedObjectMetadata, ); const fieldWorkspaceMigrationActions = - buildWorkspaceMigrationV2FieldActions( - updatedObjectMetadataDeletedCreatedUpdatedFieldMatrix, - ); + buildWorkspaceMigrationV2FieldActions({ + inferDeletionFromMissingObjectFieldIndex, + objectMetadataDeletedCreatedUpdatedFields, + }); - const updatedObjectMetadataIndexDeletedCreatedUpdatedMatrix = + const objectMetadataDeletedCreatedUpdatedIndex = computeUpdatedObjectMetadataDeletedCreatedUpdatedIndexMatrix( updatedObjectMetadata, ); - const indexWorkspaceMigrationActions = buildWorkspaceMigrationIndexActions( - updatedObjectMetadataIndexDeletedCreatedUpdatedMatrix, - ); + const indexWorkspaceMigrationActions = buildWorkspaceMigrationIndexActions({ + objectMetadataDeletedCreatedUpdatedIndex, + inferDeletionFromMissingObjectFieldIndex, + }); return { workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-field-actions-builder.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-field-actions-builder.ts index 8e37d4f7b4..79750271d5 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-field-actions-builder.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-field-actions-builder.ts @@ -10,9 +10,14 @@ import { getWorkspaceMigrationV2FieldDeleteAction, } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/get-workspace-migration-v2-field-actions'; -export const buildWorkspaceMigrationV2FieldActions = ( - objectMetadataDeletedCreatedUpdatedFields: UpdatedObjectMetadataDeletedCreatedUpdatedFieldMatrix[], -): WorkspaceMigrationFieldActionV2[] => { +type BuildWorkspaceMigrationV2FieldActionsArgs = { + inferDeletionFromMissingObjectFieldIndex: boolean; + objectMetadataDeletedCreatedUpdatedFields: UpdatedObjectMetadataDeletedCreatedUpdatedFieldMatrix[]; +}; +export const buildWorkspaceMigrationV2FieldActions = ({ + inferDeletionFromMissingObjectFieldIndex, + objectMetadataDeletedCreatedUpdatedFields, +}: BuildWorkspaceMigrationV2FieldActionsArgs): WorkspaceMigrationFieldActionV2[] => { let allUpdatedObjectMetadataFieldActions: WorkspaceMigrationFieldActionV2[] = []; @@ -53,12 +58,14 @@ export const buildWorkspaceMigrationV2FieldActions = ( }), ); - const deleteFieldAction = deletedFieldMetadata.map((flatFieldMetadata) => - getWorkspaceMigrationV2FieldDeleteAction({ - flatFieldMetadata, - flatObjectMetadata, - }), - ); + const deleteFieldAction = inferDeletionFromMissingObjectFieldIndex + ? deletedFieldMetadata.map((flatFieldMetadata) => + getWorkspaceMigrationV2FieldDeleteAction({ + flatFieldMetadata, + flatObjectMetadata, + }), + ) + : []; allUpdatedObjectMetadataFieldActions = allUpdatedObjectMetadataFieldActions.concat([ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-index-actions-builder.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-index-actions-builder.ts index a5eeddb1f3..b51767c2f1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-index-actions-builder.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-v2-index-actions-builder.ts @@ -6,9 +6,14 @@ import { getWorkspaceMigrationV2DeleteIndexAction, } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/utils/get-workspace-migration-v2-index-actions'; -export const buildWorkspaceMigrationIndexActions = ( - objectMetadataDeletedCreatedUpdatedIndex: UpdatedObjectMetadataDeletedCreatedUpdatedIndexMatrix[], -): WorkspaceMigrationIndexActionV2[] => { +type BuildWorkspaceMigrationIndexActionsArgs = { + objectMetadataDeletedCreatedUpdatedIndex: UpdatedObjectMetadataDeletedCreatedUpdatedIndexMatrix[]; + inferDeletionFromMissingObjectFieldIndex: boolean; +}; +export const buildWorkspaceMigrationIndexActions = ({ + inferDeletionFromMissingObjectFieldIndex, + objectMetadataDeletedCreatedUpdatedIndex, +}: BuildWorkspaceMigrationIndexActionsArgs): WorkspaceMigrationIndexActionV2[] => { let allUpdatedObjectMetadataIndexActions: WorkspaceMigrationIndexActionV2[] = []; @@ -36,9 +41,9 @@ export const buildWorkspaceMigrationIndexActions = ( const createIndexActions = createdIndexMetadata.map( getWorkspaceMigrationV2CreateIndexAction, ); - const deleteIndexActions = deletedIndexMetadata.map( - getWorkspaceMigrationV2DeleteIndexAction, - ); + const deleteIndexActions = inferDeletionFromMissingObjectFieldIndex + ? deletedIndexMetadata.map(getWorkspaceMigrationV2DeleteIndexAction) + : []; allUpdatedObjectMetadataIndexActions = allUpdatedObjectMetadataIndexActions.concat([ diff --git a/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts new file mode 100644 index 0000000000..6b548de829 --- /dev/null +++ b/packages/twenty-shared/src/utils/__tests__/sanitizeObjectStringFields.test.ts @@ -0,0 +1,180 @@ +import { EachTestingContext } from '@/testing/types/EachTestingContext.type'; +import { sanitizeObjectStringFields } from '../sanitizeObjectStringFields'; + +type TestObject = { + name?: string; + age?: number | null; + city?: string | undefined; + description?: string; + user?: { + name: string; + contact: { + email: string; + }; + }; + tags?: string[]; + items?: Array<{ name: string }>; + mixedArray?: Array; +}; + +type SanitizeTestCase = EachTestingContext<{ + input: { + obj: TestObject; + keys: (keyof TestObject)[]; + }; + expected: object; +}>; + +describe('sanitizeObjectStringFields', () => { + const testCases: SanitizeTestCase[] = [ + { + title: 'should handle basic string properties and trim whitespaces', + context: { + input: { + obj: { name: ' John Doe ', age: 30 }, + keys: ['name', 'age'], + }, + expected: { name: 'John Doe', age: 30 }, + }, + }, + { + title: 'should handle nested objects', + context: { + input: { + obj: { + user: { + name: ' Jane Smith ', + contact: { email: ' jane@example.com ' }, + }, + }, + keys: ['user'], + }, + expected: { + user: { + name: 'Jane Smith', + contact: { email: 'jane@example.com' }, + }, + }, + }, + }, + { + title: 'should skip undefined and null values', + context: { + input: { + obj: { name: ' John ', age: null, city: undefined }, + keys: ['name', 'age', 'city'], + }, + expected: { name: 'John', age: null, city: undefined }, + }, + }, + { + title: 'should handle empty object', + context: { + input: { + obj: {}, + keys: ['name', 'age'], + }, + expected: {}, + }, + }, + { + title: 'should handle object with no matching keys', + context: { + input: { + obj: { name: 'John', age: 30 }, + keys: ['city', 'name'], + }, + expected: { name: 'John', age: 30 }, + }, + }, + { + title: 'should handle string with multiple spaces, tabs and newlines', + context: { + input: { + obj: { description: 'This is\t\ta\n\ntest string' }, + keys: ['description'], + }, + expected: { description: 'This is a test string' }, + }, + }, + { + title: 'should handle array of strings within object', + context: { + input: { + obj: { tags: [' tag1 ', ' tag2 ', 'test string '] }, + keys: ['tags'], + }, + expected: { tags: ['tag1', 'tag2', 'test string'] }, + }, + }, + { + title: 'should handle array of objects within object', + context: { + input: { + obj: { + items: [{ name: ' John Doe ' }, { name: ' Jane Smith ' }], + }, + keys: ['items'], + }, + expected: { + items: [{ name: 'John Doe' }, { name: 'Jane Smith' }], + }, + }, + }, + { + title: 'should handle nested arrays within object properties', + context: { + input: { + obj: { + tags: [' tag1 ', ' tag2 '], + user: { + name: ' John Doe ', + contact: { email: ' john@example.com ' }, + }, + }, + keys: ['tags', 'user'], + }, + expected: { + tags: ['tag1', 'tag2'], + user: { + name: 'John Doe', + contact: { email: 'john@example.com' }, + }, + }, + }, + }, + { + title: 'should handle mixed content array within object', + context: { + input: { + obj: { + mixedArray: [ + ' string ', + 123, + null, + { text: ' nested text ' }, + ], + }, + keys: ['mixedArray'], + }, + expected: { + mixedArray: ['string', 123, null, { text: 'nested text' }], + }, + }, + }, + ]; + + test.each(testCases)( + '$title', + ({ + context: { + input: { obj, keys }, + expected, + }, + }) => { + const result = sanitizeObjectStringFields(obj, keys); + + expect(result).toEqual(expected); + }, + ); +}); diff --git a/packages/twenty-shared/src/utils/__tests__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.test.ts b/packages/twenty-shared/src/utils/__tests__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.test.ts index 07a44c0664..31603083b4 100644 --- a/packages/twenty-shared/src/utils/__tests__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/trim-and-remove-duplicated-whitespaces-from-object-string-properties.test.ts @@ -1,10 +1,11 @@ +import { eachTestingContextFilter } from '@/testing'; import { EachTestingContext } from '@/testing/types/EachTestingContext.type'; import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from '../trim-and-remove-duplicated-whitespaces-from-object-string-properties'; - type SanitizeObjectStringPropertiesTestCase = EachTestingContext<{ input: Record; keys: string[]; expected: Record; + extract?: boolean; }>; describe('trim-and-remove-duplicated-whitespaces-from-object-string-properties', () => { @@ -93,14 +94,31 @@ describe('trim-and-remove-duplicated-whitespaces-from-object-string-properties', expected: { name: ' John Doe ', description: 'this is a test' }, }, }, + { + title: 'should trim only provided keys fields and extract keys', + context: { + input: { + name: ' John Doe ', + description: ' this is a test ', + }, + keys: ['description'], + expected: { description: 'this is a test' }, + extract: true, + }, + }, ]; - test.each(testCases)('$title', ({ context: { input, keys, expected } }) => { - const result = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( - input, - keys, - ); + test.each(eachTestingContextFilter(testCases))( + '$title', + ({ context: { input, keys, expected, extract } }) => { + const result = + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( + input, + keys, + extract, + ); - expect(result).toEqual(expected); - }); + expect(result).toEqual(expected); + }, + ); }); diff --git a/packages/twenty-shared/src/utils/assertUnreachable.ts b/packages/twenty-shared/src/utils/assertUnreachable.ts index 042337aeeb..b9df32a5a2 100644 --- a/packages/twenty-shared/src/utils/assertUnreachable.ts +++ b/packages/twenty-shared/src/utils/assertUnreachable.ts @@ -1,3 +1,3 @@ -export const assertUnreachable = (x: never, errorMessage?: string): never => { +export const assertUnreachable = (_x: never, errorMessage?: string): never => { throw new Error(errorMessage ?? "Didn't expect to get here."); }; diff --git a/packages/twenty-shared/src/utils/from-array-to-unique-key-record.util.ts b/packages/twenty-shared/src/utils/from-array-to-unique-key-record.util.ts index c5df9b5453..8acefa6b65 100644 --- a/packages/twenty-shared/src/utils/from-array-to-unique-key-record.util.ts +++ b/packages/twenty-shared/src/utils/from-array-to-unique-key-record.util.ts @@ -8,18 +8,18 @@ export const fromArrayToUniqueKeyRecord = ({ array: T[]; uniqueKey: StringPropertyKeys; }) => { - return array.reduce>((acc, occurence) => { - const currentUniqueKey = occurence[uniqueKey] as string; + return array.reduce>((acc, occurrence) => { + const currentUniqueKey = occurrence[uniqueKey] as string; if (isDefined(acc[currentUniqueKey])) { throw new Error( - `Should never occur, flat array contains twice the same unique key ${occurence[uniqueKey]}`, + `Should never occur, flat array contains twice the same unique key ${occurrence[uniqueKey]}`, ); } return { ...acc, - [currentUniqueKey]: occurence, + [currentUniqueKey]: occurrence, }; }, {}); }; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index aa7e279442..81b73ab120 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -21,6 +21,7 @@ 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/sanitizeObjectStringFields.ts b/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts new file mode 100644 index 0000000000..968d5b9153 --- /dev/null +++ b/packages/twenty-shared/src/utils/sanitizeObjectStringFields.ts @@ -0,0 +1,61 @@ +import { trimAndRemoveDuplicatedWhitespacesFromString } from '@/utils/trim-and-remove-duplicated-whitespaces-from-string'; +// TODO rename with extract meaning +export const sanitizeObjectStringFields = < + T extends object, + TKeys extends (keyof T)[], +>( + obj: T, + keys: TKeys, + maxDepth: number = 10, +): { + [P in TKeys[number]]: T[P]; +} => { + const processValue = (value: unknown, currentDepth: number): unknown => { + if (value === undefined) { + return undefined; + } + + if (value === null) { + return null; + } + + if (currentDepth >= maxDepth) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => processValue(item, currentDepth)); + } + + if (typeof value === 'object') { + const obj = value as Record; + const objKeys = Object.keys(obj); + return objKeys.reduce( + (acc, key) => ({ + ...acc, + [key]: processValue(obj[key], currentDepth + 1), + }), + {}, + ); + } + + if (typeof value === 'string') { + return trimAndRemoveDuplicatedWhitespacesFromString(value); + } + + return value; + }; + + return keys.reduce((acc, key) => { + const value = processValue(obj[key], 0); + + if (value === undefined) { + return acc; + } + + return { + ...acc, + [key]: value, + }; + }, {} as T); +}; diff --git a/packages/twenty-shared/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts b/packages/twenty-shared/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts index 67bf8af866..0ac1b1a4f3 100644 --- a/packages/twenty-shared/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts +++ b/packages/twenty-shared/src/utils/trim-and-remove-duplicated-whitespaces-from-object-string-properties.ts @@ -8,24 +8,36 @@ export type StringPropertyKeys = { : never; }[OnlyStringPropertiesKey]; -export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = ( +export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = < + T, + TKeys extends StringPropertyKeys[], + TExtract extends boolean = false, +>( obj: T, - keys: StringPropertyKeys[], -) => { - return keys.reduce((acc, key) => { - const occurrence = acc[key]; - - if ( - occurrence === undefined || - typeof occurrence !== 'string' || - occurrence === null - ) { - return acc; + keys: TKeys, + extractKeys?: TExtract, +): TExtract extends true + ? { + [P in TKeys[number]]: T[P]; } + : T => { + return keys.reduce( + (acc, key) => { + const occurrence = obj[key]; - return { - ...acc, - [key]: trimAndRemoveDuplicatedWhitespacesFromString(occurrence), - }; - }, obj); + if ( + occurrence === undefined || + typeof occurrence !== 'string' || + occurrence === null + ) { + return acc; + } + + return { + ...acc, + [key]: trimAndRemoveDuplicatedWhitespacesFromString(occurrence), + }; + }, + extractKeys ? ({} as T) : obj, + ); };