CreateFieldInput transpilation to FlatFieldMetadata, FlatFieldMetadata validation (#13493)

# Introduction
Following https://github.com/twentyhq/twenty/pull/13420

What has been done:
- `CreateFieldInput` transpilation to `FlatFieldMetadata`
- `FlatFieldMetadata` validator service
- A lof of transpilation utils from `input` to `flatObject` or
`flatField`
- Created dedicated v2 api metadata services
- Introducing `inferDeletionFromMissingObjectFieldIndex` in the builder,
to avoid diffing every object and field of the current workspace we
allow only generating create/update migration operations, usefull when
passing by the api metadata


## We still need to in another PR:
- Implement a strong unit test coverage and critical functions and
services
- Finalize flat field metadata validation exception for `options`
`defaultValue` `settings` and `relations`
- Finalize `flatObjectMetadata` validation and v2 service refactor
- Plug the new service when feature flag is enabled
This commit is contained in:
Paul Rastoin
2025-07-30 15:08:11 +02:00
committed by GitHub
parent 0e4e3acaba
commit 29a4f4d685
41 changed files with 1346 additions and 351 deletions
@@ -0,0 +1,9 @@
export class AggregateError extends Error {
constructor(
public readonly errors: Error[],
message = 'Multiple errors occurred',
) {
super(message);
this.name = 'AggregateError';
}
}
@@ -118,9 +118,6 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@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<T>;
@@ -128,9 +125,6 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
@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<T>;
@@ -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',
}
@@ -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<
@@ -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(
@@ -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<FieldMetadataEntity> {
constructor(
@InjectRepository(FieldMetadataEntity, 'core')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationBuilderV2: WorkspaceMigrationBuilderV2Service,
private readonly flatFieldMetadataValidatorService: FlatFieldMetadataValidatorService,
private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service,
) {
super(fieldMetadataRepository);
}
async createMany(
fieldMetadataInputs: CreateFieldInput[],
): Promise<FieldMetadataEntity[]> {
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<FlatObjectMetadata>(
({ 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
}
}
@@ -223,10 +223,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
existingFieldMetadata.isLabelSyncedWithName;
if (isLabelSyncedWithName) {
validateNameAndLabelAreSyncOrThrow(
fieldMetadataForUpdate.label ?? existingFieldMetadata.label,
fieldMetadataForUpdate.name ?? existingFieldMetadata.name,
);
validateNameAndLabelAreSyncOrThrow({
label: fieldMetadataForUpdate.label ?? existingFieldMetadata.label,
name: fieldMetadataForUpdate.name ?? existingFieldMetadata.name,
});
}
await fieldMetadataRepository.update(id, fieldMetadataForUpdate);
@@ -696,10 +696,10 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
}
if (fieldMetadataInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow(
fieldMetadataInput.label,
fieldMetadataInput.name,
);
validateNameAndLabelAreSyncOrThrow({
label: fieldMetadataInput.label,
name: fieldMetadataInput.name,
});
}
const fieldMetadataForCreate =
@@ -29,6 +29,7 @@ export const fieldMetadataGraphqlApiExceptionHandler = (error: Error) => {
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: {
@@ -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,
);
}
};
@@ -13,13 +13,10 @@ type FlatFieldMetadataOverrides<
export const getFlatFieldMetadataMock = <T extends FieldMetadataType>(
overrides: FlatFieldMetadataOverrides<T>,
): 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 = <T extends FieldMetadataType>(
isSystem: false,
standardId: null,
standardOverrides: null,
updatedAt: createdAt,
workspaceId: faker.string.uuid(),
flatRelationTargetFieldMetadata: null,
relationTargetFieldMetadataId: null,
@@ -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,
),
};
}
}
}
}
@@ -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;
};
@@ -16,7 +16,10 @@ export type FieldMetadataEntityRelationProperties =
(typeof fieldMetadataRelationProperties)[number];
export type FlatFieldMetadata<T extends FieldMetadataType = FieldMetadataType> =
Omit<FieldMetadataEntity<T>, FieldMetadataEntityRelationProperties> & {
Omit<
FieldMetadataEntity<T>,
FieldMetadataEntityRelationProperties | 'createdAt' | 'updatedAt'
> & {
uniqueIdentifier: string;
flatRelationTargetFieldMetadata: AssignTypeIfIsRelationFieldMetadataType<
Omit<
@@ -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<T>;
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<typeof createFieldInput.type>,
parentFlatObjectMetadata,
},
];
}
case FieldMetadataType.SELECT:
case FieldMetadataType.MULTI_SELECT: {
const options = (createFieldInput?.options ?? []).map<
FieldMetadataOptions<typeof createFieldInput.type>[number]
>((option) => ({
...trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
option as FieldMetadataOptions<typeof createFieldInput.type>[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<typeof createFieldInput.type>,
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');
}
}
};
@@ -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<FieldMetadataType.RELATION>;
type: fieldMetadataEntity.type,
} satisfies FlatFieldMetadata<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>;
}
const fieldMetadataWithoutRelations = removePropertiesFromRecord(
fieldMetadataEntity,
fieldMetadataRelationProperties,
);
return {
...fieldMetadataWithoutRelations,
uniqueIdentifier:
@@ -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<FieldMetadataType.RELATION>,
'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<FieldMetadataType.RELATION> =
{
...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<FieldMetadataType.RELATION>[];
};
@@ -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;
};
@@ -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;
};
@@ -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,
};
@@ -20,7 +20,11 @@ type ObjectMetadataRelationProperties = ExtractRecordTypeOrmRelationProperties<
export type FlatObjectMetadata = Omit<
ObjectMetadataEntity,
ObjectMetadataRelationProperties
| ObjectMetadataRelationProperties
| 'dataSourceId'
| 'createdAt'
| 'updatedAt'
| 'duplicateCriteria'
> & {
uniqueIdentifier: string;
flatIndexMetadatas: FlatIndexMetadata[];
@@ -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,
@@ -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,
};
};
@@ -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[], 'object'>): FlatObjectMetadata[] => {
@@ -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,
};
};
@@ -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<ObjectMetadataEntity> {
constructor(
@InjectRepository(ObjectMetadataEntity, 'core')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationBuilderV2: WorkspaceMigrationBuilderV2Service,
private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service,
) {
super(objectMetadataRepository);
}
override async createOne(
objectMetadataInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
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
}
}
@@ -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<ObjectMetadataEntity> {
@@ -147,18 +144,16 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
toValidate: [createdRawFlatObjectMetadata],
});
const workpsaceMigration = this.workspaceMigrationBuilderV2.build({
const workspaceMigration = this.workspaceMigrationBuilderV2.build({
objectMetadataFromToInputs: {
from: existingFlatObjectMetadatas,
to: mergeTwoFlatFieldObjectMetadatas({
destFlatObjectMetadatas: existingFlatObjectMetadatas,
toMergeFlatObjectMetadatas: [createdFlatObjectMetadata],
}),
to: [createdFlatObjectMetadata],
},
workspaceId: objectMetadataInput.workspaceId, // Where does this comes from ?
inferDeletionFromMissingObjectFieldIndex: false,
workspaceId: objectMetadataInput.workspaceId,
});
await this.workspaceMigrationRunnerV2Service.run(workpsaceMigration);
await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
// What to return exactly ? We now won't have access to the entity directly
// We could still retrieve it afterwards using a find on object metadata id or return a flat now
@@ -193,14 +188,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
});
if (objectMetadataInput.isLabelSyncedWithName === true) {
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelSingular,
objectMetadataInput.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
objectMetadataInput.labelPlural,
objectMetadataInput.namePlural,
);
validateNameAndLabelAreSyncOrThrow({
label: objectMetadataInput.labelSingular,
name: objectMetadataInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: objectMetadataInput.labelPlural,
name: objectMetadataInput.namePlural,
});
}
validatesNoOtherObjectWithSameNameExistsOrThrows({
@@ -358,14 +353,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
objectMetadataMaps,
});
if (existingObjectMetadataCombinedWithUpdateInput.isLabelSyncedWithName) {
validateNameAndLabelAreSyncOrThrow(
existingObjectMetadataCombinedWithUpdateInput.labelSingular,
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
);
validateNameAndLabelAreSyncOrThrow(
existingObjectMetadataCombinedWithUpdateInput.labelPlural,
existingObjectMetadataCombinedWithUpdateInput.namePlural,
);
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelSingular,
name: existingObjectMetadataCombinedWithUpdateInput.nameSingular,
});
validateNameAndLabelAreSyncOrThrow({
label: existingObjectMetadataCombinedWithUpdateInput.labelPlural,
name: existingObjectMetadataCombinedWithUpdateInput.namePlural,
});
}
if (
isDefined(inputPayload.nameSingular) ||
@@ -117,25 +117,21 @@ export const buildDefaultFieldsForCustomObject = (
];
type BuildDefaultFlatFieldMetadataForCustomObjectArgs = {
createdAt: Date;
workspaceId: string;
objectMetadataId: string;
};
export const buildDefaultFlatFieldMetadataForCustomObject = ({
createdAt,
workspaceId,
objectMetadataId,
}: BuildDefaultFlatFieldMetadataForCustomObjectArgs) => {
const idField: FlatFieldMetadata<FieldMetadataType.UUID> = {
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<FieldMetadataType.TEXT> = {
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<FieldMetadataType.DATE_TIME> = {
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<FieldMetadataType.DATE_TIME> = {
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<FieldMetadataType.DATE_TIME> = {
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<FieldMetadataType.ACTOR> = {
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<FieldMetadataType.POSITION> = {
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',
@@ -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();
}
},
@@ -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);
@@ -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) {
@@ -6,7 +6,6 @@ exports[`Workspace migration builder field actions test suite It should build a
[
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -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<ClockDate>,
"workspaceId": Any<String>,
},
"type": "delete_field",
@@ -70,7 +64,6 @@ exports[`Workspace migration builder field actions test suite It should build an
[
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -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<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
@@ -134,11 +122,9 @@ exports[`Workspace migration builder field actions test suite It should build an
[
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"defaultValue": null,
"description": "default flat field metadata description",
"flatRelationTargetFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatRelationTargetObjectMetadata": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -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<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
@@ -254,7 +230,6 @@ exports[`Workspace migration builder field actions test suite It should build an
[
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -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<ClockDate>,
"workspaceId": Any<String>,
},
"type": "update_field",
@@ -341,7 +311,6 @@ exports[`Workspace migration builder field actions test suite It should build an
[
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -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<ClockDate>,
"workspaceId": Any<String>,
},
"type": "update_field",
@@ -511,10 +475,7 @@ exports[`Workspace migration builder object actions test suite It should build a
{
"createFieldActions": [],
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -535,7 +496,6 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_object",
@@ -549,7 +509,6 @@ exports[`Workspace migration builder object actions test suite It should build a
"createFieldActions": [
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -601,14 +556,12 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
},
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -660,14 +609,12 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
},
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -719,14 +662,12 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
},
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -778,14 +715,12 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
},
{
"flatFieldMetadata": {
"createdAt": Any<ClockDate>,
"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<ClockDate>,
"workspaceId": Any<String>,
},
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -837,17 +768,13 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_field",
},
],
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -868,7 +795,6 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "create_object",
@@ -897,10 +823,7 @@ exports[`Workspace migration builder object actions test suite It should build a
[
{
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -921,7 +844,6 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "delete_object",
@@ -933,10 +855,7 @@ exports[`Workspace migration builder object actions test suite It should build a
[
{
"flatObjectMetadataWithoutFields": {
"createdAt": Any<ClockDate>,
"dataSourceId": Any<String>,
"description": "default flat object metadata description",
"duplicateCriteria": [],
"icon": "icon",
"id": Any<String>,
"imageIdentifierFieldMetadataId": Any<String>,
@@ -957,7 +876,6 @@ exports[`Workspace migration builder object actions test suite It should build a
"standardOverrides": null,
"targetTableName": "",
"uniqueIdentifier": "pomme",
"updatedAt": Any<ClockDate>,
"workspaceId": Any<String>,
},
"type": "update_object",
@@ -19,9 +19,11 @@ export class WorkspaceMigrationBuilderV2Service {
build({
objectMetadataFromToInputs,
workspaceId,
inferDeletionFromMissingObjectFieldIndex = true,
}: {
objectMetadataFromToInputs: FromTo<FlatObjectMetadata[]>;
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,
@@ -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([
@@ -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([
@@ -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<string | number | null | { text: string }>;
};
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);
},
);
});
@@ -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<string, any>;
keys: string[];
expected: Record<string, any>;
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);
},
);
});
@@ -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.");
};
@@ -8,18 +8,18 @@ export const fromArrayToUniqueKeyRecord = <T extends object>({
array: T[];
uniqueKey: StringPropertyKeys<T>;
}) => {
return array.reduce<Record<string, T>>((acc, occurence) => {
const currentUniqueKey = occurence[uniqueKey] as string;
return array.reduce<Record<string, T>>((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,
};
}, {});
};
@@ -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';
@@ -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<string, unknown>;
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);
};
@@ -8,24 +8,36 @@ export type StringPropertyKeys<T> = {
: never;
}[OnlyStringPropertiesKey<T>];
export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = <T>(
export const trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties = <
T,
TKeys extends StringPropertyKeys<T>[],
TExtract extends boolean = false,
>(
obj: T,
keys: StringPropertyKeys<T>[],
) => {
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,
);
};