ObjectMetadataServiceV2.createOne (#13849)

# Introduction

Introducing v2 object metadata service create one handler v2, not
overkilling the flatObjectMetadata validation for the moment that will
require sequential validation for the import in order to handle
relations and morph relations
This commit is contained in:
Paul Rastoin
2025-08-12 16:49:35 +02:00
committed by GitHub
parent c94f33f1b7
commit de0468d104
26 changed files with 573 additions and 176 deletions
@@ -326,7 +326,7 @@ export class FieldMetadataServiceV2 {
});
const validationErrors =
await this.flatFieldMetadataValidatorService.validateOneFlatFieldMetadataCreation(
await this.flatFieldMetadataValidatorService.validateFlatFieldMetadataCreation(
{
existingFlatObjectMetadataMaps: optimisticFlatObjectMetadataMaps,
flatFieldMetadataToValidate: flatFieldMetadataToCreate,
@@ -17,6 +17,7 @@ import {
} from 'src/engine/metadata-modules/flat-field-metadata/utils/compare-two-flat-field-metadata.util';
import { isFlatFieldMetadataEntityOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
import { validateFlatFieldMetadataNameAvailability } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name-availability.validator';
import { validateFlatFieldMetadataName } from 'src/engine/metadata-modules/flat-field-metadata/validators/validate-flat-field-metadata-name.validator';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { fromFlatObjectMetadataWithFlatFieldMapsToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-with-flat-field-maps-to-flat-object-metadatas.util';
import {
@@ -27,7 +28,6 @@ import {
InvalidMetadataException,
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
import { validateMetadataName } 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';
export type ValidateOneFieldMetadataArgs<
@@ -141,14 +141,18 @@ export class FlatFieldMetadataValidatorService {
if (updates.some((update) => update.property === 'name')) {
errors.push(
...[
validateMetadataName(updatedFlatFieldMetadata.name),
validateFlatFieldMetadataNameAvailability({
name: updatedFlatFieldMetadata.name,
flatObjectMetadata: flatObjectMetadata,
}),
].filter(isDefined),
...validateFlatFieldMetadataName(updatedFlatFieldMetadata.name),
);
const nameAvailabilityValidationError =
validateFlatFieldMetadataNameAvailability({
name: updatedFlatFieldMetadata.name,
flatObjectMetadata: flatObjectMetadata,
});
if (isDefined(nameAvailabilityValidationError)) {
errors.push(nameAvailabilityValidationError);
}
}
if (updatedFlatFieldMetadata.isLabelSyncedWithName) {
@@ -241,7 +245,7 @@ export class FlatFieldMetadataValidatorService {
return errors;
}
async validateOneFlatFieldMetadataCreation<
async validateFlatFieldMetadataCreation<
T extends FieldMetadataType = FieldMetadataType,
>({
existingFlatObjectMetadataMaps,
@@ -306,27 +310,20 @@ export class FlatFieldMetadataValidatorService {
}
}
const nameValidationResult = validateMetadataName(
flatFieldMetadataToValidate.name,
errors.push(
...validateFlatFieldMetadataName(flatFieldMetadataToValidate.name),
);
if (isDefined(nameValidationResult)) {
errors.push(nameValidationResult);
}
const fieldMetadataTypeValidationErrors =
await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
errors.push(
...(await this.flatFieldMetadataTypeValidatorService.validateFlatFieldMetadataTypeSpecificities(
{
existingFlatObjectMetadataMaps,
flatFieldMetadataToValidate,
workspaceId,
otherFlatObjectMetadataMapsToValidate,
},
);
if (fieldMetadataTypeValidationErrors.length > 0) {
errors.push(...fieldMetadataTypeValidationErrors);
}
)),
);
return errors;
}
@@ -1,10 +1,9 @@
import { isDefined } from 'twenty-shared/utils';
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { runFlatFieldMetadataValidator } from 'src/engine/metadata-modules/flat-field-metadata/utils/run-flat-field-metadata-validator.util';
import { type ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import { type InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export type FailedFlatFieldMetadataValidationExceptions =
@@ -12,31 +11,9 @@ export type FailedFlatFieldMetadataValidationExceptions =
| ObjectMetadataException
| InvalidMetadataException;
export type FlatFieldMetadataValidator<T> = {
validator: (value: T) => boolean;
message: string;
};
export const runFlatFieldMetadataValidator = <T>(
elementToValidate: T,
{ message, validator }: FlatFieldMetadataValidator<T>,
): FailedFlatFieldMetadataValidationExceptions | undefined => {
const validationFailed = validator(elementToValidate);
if (validationFailed) {
return new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
};
export const runFlatFieldMetadataValidators = <T>(
elementToValidate: T,
validators: FlatFieldMetadataValidator<T>[],
validators: FlatMetadataValidator<T>[],
): FailedFlatFieldMetadataValidationExceptions[] =>
validators
.map((validator) =>
@@ -0,0 +1,23 @@
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatFieldMetadataValidator = <T>(
elementToValidate: T,
{ message, validator }: FlatMetadataValidator<T>,
): FailedFlatFieldMetadataValidationExceptions | undefined => {
const isInvalid = validator(elementToValidate);
if (isInvalid) {
return new FieldMetadataException(
message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: message,
},
);
}
};
@@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { runFlatFieldMetadataValidator } from 'src/engine/metadata-modules/flat-field-metadata/utils/run-flat-field-metadata-validator.util';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatFieldMetadataValidators = <T>(
elementToValidate: T,
validators: FlatMetadataValidator<T>[],
): FailedFlatFieldMetadataValidationExceptions[] =>
validators
.map((validator) =>
runFlatFieldMetadataValidator(elementToValidate, validator),
)
.filter(isDefined);
@@ -2,8 +2,8 @@ import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { QUOTED_STRING_REGEX } from 'twenty-shared/constants';
import {
type EnumFieldMetadataType,
FieldMetadataType,
type EnumFieldMetadataType,
type NonNullableRequired,
} from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -21,11 +21,11 @@ import {
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type ValidateOneFieldMetadataArgs } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import {
type FailedFlatFieldMetadataValidationExceptions,
type FlatFieldMetadataValidator,
runFlatFieldMetadataValidators,
type FailedFlatFieldMetadataValidationExceptions,
} from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
@@ -33,7 +33,7 @@ import {
import { isSnakeCaseString } from 'src/utils/is-snake-case-string';
const validateMetadataOptionId = (sanitizedId?: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
const validators: FlatMetadataValidator<string>[] = [
{
validator: (id) => !isDefined(id),
message: t`Option id is required`,
@@ -48,7 +48,7 @@ const validateMetadataOptionId = (sanitizedId?: string) => {
};
const validateMetadataOptionLabel = (sanitizedLabel: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
const validators: FlatMetadataValidator<string>[] = [
{
validator: (label) => !isDefined(label),
message: t`Option label is required`,
@@ -75,7 +75,7 @@ const validateMetadataOptionLabel = (sanitizedLabel: string) => {
};
const validateMetadataOptionValue = (sanitizedValue: string) => {
const validators: FlatFieldMetadataValidator<string>[] = [
const validators: FlatMetadataValidator<string>[] = [
{
validator: (value) => !isDefined(value),
message: t`Option value is required`,
@@ -109,7 +109,7 @@ const validateDuplicates = (
| FieldMetadataComplexOption[]
)[number])[];
const duplicatedValidators = fieldsToCheckForDuplicates.map<
FlatFieldMetadataValidator<
FlatMetadataValidator<
FieldMetadataDefaultOption[] | FieldMetadataComplexOption[]
>
>((field) => ({
@@ -161,7 +161,7 @@ const validateSelectDefaultValue = (
];
}
const validators: FlatFieldMetadataValidator<string>[] = [
const validators: FlatMetadataValidator<string>[] = [
{
validator: (value: string) => !QUOTED_STRING_REGEX.test(value),
message: 'Default value should be as quoted string',
@@ -191,7 +191,7 @@ const validateMultiSelectDefaultValue = (
];
}
const validators: FlatFieldMetadataValidator<string[]>[] = [
const validators: FlatMetadataValidator<string[]>[] = [
{
validator: (values) => values.length === 0,
message: 'If defined default value must contain at least one value',
@@ -0,0 +1,8 @@
import { type FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { runFlatFieldMetadataValidators } from 'src/engine/metadata-modules/flat-field-metadata/utils/run-flat-field-metadata-validators.util';
import { METADATA_NAME_VALIDATORS } from 'src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants';
export const validateFlatFieldMetadataName = (
name: string,
): FailedFlatFieldMetadataValidationExceptions[] =>
runFlatFieldMetadataValidators(name, METADATA_NAME_VALIDATORS);
@@ -0,0 +1,154 @@
import { Injectable } from '@nestjs/common';
import { t } from '@lingui/core/macro';
import { FlatFieldMetadataValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FailedFlatFieldMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-field-metadata/types/failed-flat-field-metadata-validation.type';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import { addFlatFieldMetadataInFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/add-flat-field-metadata-in-flat-object-metadata-maps-or-throw.util';
import { addFlatObjectMetadataToFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/add-flat-object-metadata-to-flat-object-metadata-maps-or-throw.util';
import { FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { validateFlatObjectMetadataLabel } from 'src/engine/metadata-modules/flat-object-metadata/validators/validate-flat-object-metadata-label.validator';
import { validateFlatObjectMetadataNames } from 'src/engine/metadata-modules/flat-object-metadata/validators/validate-flat-object-metadata-name.validator';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/validate-name-and-label-are-sync-or-throw.util';
import { doesOtherObjectWithSameNameExists } from 'src/engine/metadata-modules/utils/validate-no-other-object-with-same-name-exists-or-throw.util';
export type ValidateOneFlatObjectMetadataArgs = {
existingFlatObjectMetadataMaps: FlatObjectMetadataMaps;
flatObjectMetadataToValidate: FlatObjectMetadata;
otherFlatObjectMetadataMapsToValidate?: FlatObjectMetadataMaps;
workspaceId: string;
};
@Injectable()
export class FlatObjectMetadataValidatorService {
constructor(
private readonly flatFieldMetadataValidatorService: FlatFieldMetadataValidatorService,
) {}
public async validateFlatObjectMetadataCreation({
existingFlatObjectMetadataMaps,
flatObjectMetadataToValidate,
workspaceId,
}: ValidateOneFlatObjectMetadataArgs) {
const errors: FailedFlatObjectMetadataValidationExceptions[] = [];
errors.push(
...validateFlatObjectMetadataNames({
namePlural: flatObjectMetadataToValidate.namePlural,
nameSingular: flatObjectMetadataToValidate.nameSingular,
}),
);
errors.push(
...validateFlatObjectMetadataLabel({
labelPlural: flatObjectMetadataToValidate.labelPlural,
labelSingular: flatObjectMetadataToValidate.labelSingular,
}),
);
if (flatObjectMetadataToValidate.isRemote) {
errors.push(
new ObjectMetadataException(
t`Remote objects are not supported yet`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
),
);
}
if (flatObjectMetadataToValidate.isLabelSyncedWithName === true) {
const computedNameSingular = computeMetadataNameFromLabel(
flatObjectMetadataToValidate.labelSingular,
);
if (computedNameSingular !== flatObjectMetadataToValidate.nameSingular) {
errors.push(
new ObjectMetadataException(
t`Singular name is not synced with singular label`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
),
);
}
const computedNamePlural = computeMetadataNameFromLabel(
flatObjectMetadataToValidate.labelPlural,
);
if (computedNamePlural !== flatObjectMetadataToValidate.namePlural) {
errors.push(
new ObjectMetadataException(
t`Plural name is not synced with plural label`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
),
);
}
}
if (
doesOtherObjectWithSameNameExists({
objectMetadataNamePlural: flatObjectMetadataToValidate.namePlural,
objectMetadataNameSingular: flatObjectMetadataToValidate.nameSingular,
objectMetadataMaps: existingFlatObjectMetadataMaps,
})
) {
errors.push(
new ObjectMetadataException(
'Object already exists',
ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS,
{
userFriendlyMessage: t`Object already exists`,
},
),
);
}
const allFlatFieldMetadatasValidationErrors: FailedFlatFieldMetadataValidationExceptions[] =
[];
let existingFlatObjectMetadataMapsWithFlatObjectMetadataToBeCreatedWithoutFields =
addFlatObjectMetadataToFlatObjectMetadataMapsOrThrow({
flatObjectMetadata: {
...flatObjectMetadataToValidate,
flatFieldMetadatas: [],
},
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
});
for (const flatFieldMetadataToValidate of flatObjectMetadataToValidate.flatFieldMetadatas) {
const otherFlatObjectMetadataMapsToValidate = undefined; // TODO prastoin when implementing import
const flatFieldValidatorErrors =
await this.flatFieldMetadataValidatorService.validateFlatFieldMetadataCreation(
{
existingFlatObjectMetadataMaps:
existingFlatObjectMetadataMapsWithFlatObjectMetadataToBeCreatedWithoutFields,
flatFieldMetadataToValidate,
workspaceId,
otherFlatObjectMetadataMapsToValidate,
},
);
if (flatFieldValidatorErrors.length > 0) {
allFlatFieldMetadatasValidationErrors.push(...flatFieldValidatorErrors);
continue;
}
existingFlatObjectMetadataMapsWithFlatObjectMetadataToBeCreatedWithoutFields =
addFlatFieldMetadataInFlatObjectMetadataMapsOrThrow({
flatFieldMetadata: flatFieldMetadataToValidate,
flatObjectMetadataMaps:
existingFlatObjectMetadataMapsWithFlatObjectMetadataToBeCreatedWithoutFields,
});
}
if (allFlatFieldMetadatasValidationErrors.length > 0) {
errors.push(...allFlatFieldMetadatasValidationErrors);
}
return errors;
}
}
@@ -0,0 +1,8 @@
import { type FieldMetadataException } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { type ObjectMetadataException } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type InvalidMetadataException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
export type FailedFlatObjectMetadataValidationExceptions =
| FieldMetadataException
| ObjectMetadataException
| InvalidMetadataException;
@@ -1,24 +1,20 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import {
capitalize,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type 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,
);
}
export const fromCreateObjectInputToFlatObjectMetadata = ({
objectMetadataInput: rawCreateObjectInput,
workspaceId,
}: {
objectMetadataInput: Omit<CreateObjectInput, 'workspaceId'>;
workspaceId: string;
}): FlatObjectMetadata => {
const createObjectInput =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreateObjectInput,
@@ -37,7 +33,7 @@ export const fromCreateObjectInputToFlatObjectMetadata = (
const baseCustomFlatFieldMetadatas =
buildDefaultFlatFieldMetadataForCustomObject({
objectMetadataId,
workspaceId: createObjectInput.workspaceId,
workspaceId,
});
return {
@@ -51,19 +47,19 @@ export const fromCreateObjectInputToFlatObjectMetadata = (
isAuditLogged: true,
isCustom: true,
isLabelSyncedWithName: createObjectInput.isLabelSyncedWithName ?? false,
isRemote: false,
isRemote: createObjectInput.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,
labelPlural: capitalize(createObjectInput.labelPlural),
labelSingular: capitalize(createObjectInput.labelSingular),
namePlural: createObjectInput.namePlural,
nameSingular: createObjectInput.nameSingular,
shortcut: createObjectInput.shortcut ?? null,
standardId: null,
standardOverrides: null,
uniqueIdentifier: objectMetadataId,
targetTableName: 'DEPRECATED',
workspaceId: createObjectInput.workspaceId,
workspaceId,
};
};
@@ -0,0 +1,23 @@
import { type FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatObjectMetadataValidator = <T>(
elementToValidate: T,
{ message, validator }: FlatMetadataValidator<T>,
): FailedFlatObjectMetadataValidationExceptions | undefined => {
const validationFailed = validator(elementToValidate);
if (validationFailed) {
return new ObjectMetadataException(
message,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
{
userFriendlyMessage: message,
},
);
}
};
@@ -0,0 +1,15 @@
import { isDefined } from 'twenty-shared/utils';
import { type FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { runFlatObjectMetadataValidator } from 'src/engine/metadata-modules/flat-object-metadata/utils/run-flat-object-metadata-validator.util';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
export const runFlatObjectMetadataValidators = <T>(
elementToValidate: T,
validators: FlatMetadataValidator<T>[],
): FailedFlatObjectMetadataValidationExceptions[] =>
validators
.map((validator) =>
runFlatObjectMetadataValidator(elementToValidate, validator),
)
.filter(isDefined);
@@ -0,0 +1,54 @@
import { t } from '@lingui/core/macro';
import { type FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { runFlatObjectMetadataValidators } from 'src/engine/metadata-modules/flat-object-metadata/utils/run-flat-object-metadata-validators.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
export const validateFlatObjectMetadataLabel = ({
labelPlural,
labelSingular,
}: Pick<
FlatObjectMetadata,
'labelPlural' | 'labelSingular'
>): FailedFlatObjectMetadataValidationExceptions[] => {
const errors: FailedFlatObjectMetadataValidationExceptions[] = [];
const validators: FlatMetadataValidator<string>[] = [
{
validator: (label) => beneathDatabaseIdentifierMinimumLength(label),
message: t`Object label is too short`,
},
{
validator: (label) => exceedsDatabaseIdentifierMaximumLength(label),
message: t`Object label is too long`,
},
];
errors.push(
...[labelSingular, labelPlural].flatMap((label) =>
runFlatObjectMetadataValidators(label, validators),
),
);
const labelsAreIdentical =
labelSingular.trim().toLowerCase() === labelPlural.trim().toLowerCase();
if (labelsAreIdentical) {
errors.push(
new ObjectMetadataException(
t`The singular and plural labels cannot be the same for an object`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
),
);
}
return errors;
};
@@ -0,0 +1,37 @@
import { t } from '@lingui/core/macro';
import { type FailedFlatObjectMetadataValidationExceptions } from 'src/engine/metadata-modules/flat-object-metadata/types/failed-flat-object-metadata-validation.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { runFlatObjectMetadataValidators } from 'src/engine/metadata-modules/flat-object-metadata/utils/run-flat-object-metadata-validators.util';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { METADATA_NAME_VALIDATORS } from 'src/engine/metadata-modules/utils/constants/metadata-name-flat-metadata-validators.constants';
export const validateFlatObjectMetadataNames = ({
namePlural,
nameSingular,
}: Pick<FlatObjectMetadata, 'nameSingular' | 'namePlural'>) => {
const errors: FailedFlatObjectMetadataValidationExceptions[] = [];
errors.push(
...[nameSingular, namePlural].flatMap((name) =>
runFlatObjectMetadataValidators(name, METADATA_NAME_VALIDATORS),
),
);
const namesAreIdentical =
namePlural.trim().toLowerCase() === nameSingular.trim().toLowerCase();
if (namesAreIdentical) {
errors.push(
new ObjectMetadataException(
t`The singular and plural names cannot be the same for an object`,
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
),
);
}
return errors;
};
@@ -1,64 +1,85 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { Repository } from 'typeorm';
import { MultipleMetadataValidationErrors } from 'src/engine/core-modules/error/multiple-metadata-validation-errors';
import { EMPTY_FLAT_OBJECT_METADATA_MAPS } from 'src/engine/metadata-modules/flat-object-metadata-maps/constant/empty-flat-object-metadata-maps.constant';
import { addFlatObjectMetadataToFlatObjectMetadataMapsOrThrow } from 'src/engine/metadata-modules/flat-object-metadata-maps/utils/add-flat-object-metadata-to-flat-object-metadata-maps-or-throw.util';
import { FlatObjectMetadataValidatorService } from 'src/engine/metadata-modules/flat-object-metadata/services/flat-object-metadata-validator.service';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { fromCreateObjectInputToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata.util';
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 { 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';
@Injectable()
export class ObjectMetadataServiceV2 extends TypeOrmQueryService<ObjectMetadataEntity> {
export class ObjectMetadataServiceV2 {
constructor(
@InjectRepository(ObjectMetadataEntity, 'core')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly workspaceMigrationBuilderV2: WorkspaceMigrationBuilderV2Service,
private readonly workspaceMigrationRunnerV2Service: WorkspaceMigrationRunnerV2Service,
) {
super(objectMetadataRepository);
private readonly flatObjectMetadataValidatorService: FlatObjectMetadataValidatorService,
) {}
async createOne({
objectMetadataInput,
workspaceId,
}: {
objectMetadataInput: Omit<CreateObjectInput, 'workspaceId'>;
workspaceId: string;
}): Promise<FlatObjectMetadata> {
const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } =
await this.workspaceMetadataCacheService.getExistingOrRecomputeFlatObjectMetadataMaps(
{
workspaceId,
},
);
const flatObjectMetadataToCreate =
fromCreateObjectInputToFlatObjectMetadata({
objectMetadataInput,
workspaceId,
});
const validationErrors =
await this.flatObjectMetadataValidatorService.validateFlatObjectMetadataCreation(
{
existingFlatObjectMetadataMaps,
flatObjectMetadataToValidate: flatObjectMetadataToCreate,
workspaceId,
},
);
if (validationErrors.length > 0) {
throw new MultipleMetadataValidationErrors(
validationErrors,
'Multiple validation errors occurred while creating object',
);
}
try {
const workspaceMigration = this.workspaceMigrationBuilderV2.build({
fromFlatObjectMetadataMaps: EMPTY_FLAT_OBJECT_METADATA_MAPS,
toFlatObjectMetadataMaps:
addFlatObjectMetadataToFlatObjectMetadataMapsOrThrow({
flatObjectMetadataMaps: EMPTY_FLAT_OBJECT_METADATA_MAPS,
flatObjectMetadata: flatObjectMetadataToCreate,
}),
inferDeletionFromMissingObjectFieldIndex: false,
workspaceId,
});
await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
} catch {
throw new ObjectMetadataException(
'Workspace migration failed to run',
ObjectMetadataExceptionCode.INTERNAL_SERVER_ERROR,
);
}
return flatObjectMetadataToCreate; // TODO retrieve from cache
}
// override async createOne(
// objectMetadataInput: CreateObjectInput,
// ): Promise<ObjectMetadataEntity> {
// const { flatObjectMetadataMaps } =
// await this.workspaceMetadataCacheService.getExistingOrRecomputeFlatObjectMetadataMaps(
// {
// workspaceId: objectMetadataInput.workspaceId,
// },
// );
// const createdRawFlatObjectMetadata =
// fromCreateObjectInputToFlatObjectMetadata(objectMetadataInput);
// const existingFlatObjectMetadatas: FlatObjectMetadataMaps = {
// byId: {},
// idByNameSingular: {},
// };
// // @ts-expect-error TODO implement validateFlatObjectMetadata
// const createdFlatObjectMetadata = validateFlatObjectMetadata({
// existing:
// // Here we assume that EVERYTHING is in cache and up to date, this is very critical, also race condition prone :thinking:
// fromFlatObjectMetadataMapsToFlatObjectMetadatas(flatObjectMetadataMaps),
// toValidate: [createdRawFlatObjectMetadata],
// });
// const workspaceMigration = this.workspaceMigrationBuilderV2.build({
// objectMetadataFromToInputs: {
// from: fromFlatObjectMetadataMapsToFlatObjectMetadatas(
// existingFlatObjectMetadatas,
// ),
// to: [createdFlatObjectMetadata],
// },
// inferDeletionFromMissingObjectFieldIndex: false,
// workspaceId: objectMetadataInput.workspaceId,
// });
// await this.workspaceMigrationRunnerV2Service.run(workspaceMigration);
// return createdFlatObjectMetadata; // TODO retrieve from cache
// }
}
@@ -9,4 +9,5 @@ export enum ObjectMetadataExceptionCode {
OBJECT_ALREADY_EXISTS = 'OBJECT_ALREADY_EXISTS',
MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD = 'MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD',
INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}
@@ -15,10 +15,19 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FlatFieldMetadataValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-validator.service';
import { FlatObjectMetadataValidatorService } from 'src/engine/metadata-modules/flat-object-metadata/services/flat-object-metadata-validator.service';
import { IndexMetadataModule } from 'src/engine/metadata-modules/index-metadata/index-metadata.module';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { ObjectMetadataGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/object-metadata/interceptors/object-metadata-graphql-api-exception.interceptor';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataResolver } from 'src/engine/metadata-modules/object-metadata/object-metadata.resolver';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { ObjectMetadataFieldRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-field-relation.service';
import { ObjectMetadataMigrationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-migration.service';
import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-related-records.service';
@@ -37,13 +46,6 @@ import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/wor
import { WorkspaceMigrationBuilderV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/workspace-migration-builder-v2.module';
import { WorkspaceMigrationRunnerV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/workspace-migration-runner-v2.module';
import { ObjectMetadataEntity } from './object-metadata.entity';
import { ObjectMetadataService } from './object-metadata.service';
import { CreateObjectInput } from './dtos/create-object.input';
import { ObjectMetadataDTO } from './dtos/object-metadata.dto';
import { UpdateObjectPayload } from './dtos/update-object.input';
@Module({
imports: [
NestjsQueryGraphQLModule.forFeature({
@@ -72,6 +74,10 @@ import { UpdateObjectPayload } from './dtos/update-object.input';
],
services: [
ObjectMetadataService,
ObjectMetadataServiceV2,
FlatObjectMetadataValidatorService,
FlatFieldMetadataValidatorService,
FlatFieldMetadataTypeValidatorService,
ObjectMetadataMigrationService,
ObjectMetadataFieldRelationService,
ObjectMetadataRelatedRecordsService,
@@ -102,6 +108,7 @@ import { UpdateObjectPayload } from './dtos/update-object.input';
],
providers: [
ObjectMetadataService,
ObjectMetadataServiceV2,
ObjectMetadataResolver,
BeforeUpdateOneObject,
],
@@ -21,8 +21,8 @@ import { IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dto
import { DeleteOneObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/delete-object.input';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import {
type UpdateObjectPayload,
UpdateOneObjectInput,
type UpdateObjectPayload,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { BeforeUpdateOneObject } from 'src/engine/metadata-modules/object-metadata/hooks/before-update-one-object.hook';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
@@ -6,13 +6,15 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadataType } from 'twenty-shared/types';
import { capitalize, isDefined } from 'twenty-shared/utils';
import {
In,
Repository,
type FindManyOptions,
type FindOneOptions,
In,
type QueryRunner,
Repository,
} from 'typeorm';
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 { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
@@ -21,6 +23,7 @@ import {
type UpdateObjectPayload,
type UpdateOneObjectInput,
} from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@@ -70,6 +73,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
private readonly indexMetadataService: IndexMetadataService,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataServiceV2: ObjectMetadataServiceV2,
) {
super(objectMetadataRepository);
}
@@ -93,6 +98,38 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
override async createOne(
objectMetadataInput: CreateObjectInput,
): Promise<ObjectMetadataEntity> {
const isWorkspaceMigrationV2Enabled =
await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED,
objectMetadataInput.workspaceId,
);
if (isWorkspaceMigrationV2Enabled) {
const flatObjectMetadata = await this.objectMetadataServiceV2.createOne({
objectMetadataInput,
workspaceId: objectMetadataInput.workspaceId,
});
// Since V2 returns FlatObjectMetadata, we need to fetch the created entity
const createdObjectMetadata = await this.objectMetadataRepository.findOne(
{
where: {
id: flatObjectMetadata.id,
workspaceId: objectMetadataInput.workspaceId,
},
},
);
if (!isDefined(createdObjectMetadata)) {
throw new ObjectMetadataException(
'Created object metadata not found',
ObjectMetadataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
return createdObjectMetadata;
}
const mainDataSource =
await this.workspaceDataSourceService.connectToMainDataSource();
const queryRunner = mainDataSource.createQueryRunner();
@@ -28,6 +28,7 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => {
throw new ForbiddenError(error);
case ObjectMetadataExceptionCode.OBJECT_ALREADY_EXISTS:
throw new ConflictError(error);
case ObjectMetadataExceptionCode.INTERNAL_SERVER_ERROR:
case ObjectMetadataExceptionCode.INVALID_ORM_OUTPUT:
throw new InternalServerError(error);
case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD:
@@ -0,0 +1,4 @@
export type FlatMetadataValidator<T> = {
validator: (value: T) => boolean;
message: string;
};
@@ -0,0 +1,36 @@
import { t } from '@lingui/core/macro';
import camelCase from 'lodash.camelcase';
import { type FlatMetadataValidator } from 'src/engine/metadata-modules/types/flat-metadata-validator.type';
import {
beneathDatabaseIdentifierMinimumLength,
exceedsDatabaseIdentifierMaximumLength,
} from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
import { RESERVED_METADATA_NAME_KEYWORDS } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
import { STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
export const METADATA_NAME_VALIDATORS: FlatMetadataValidator<string>[] = [
{
message: t`Name is too long`,
validator: (name) => exceedsDatabaseIdentifierMaximumLength(name),
},
{
message: t`Name is too short`,
validator: (name) => beneathDatabaseIdentifierMinimumLength(name),
},
{
message: t`Name should be in camelCase`,
validator: (name) => name !== camelCase(name),
},
{
message: t`Name is not valid: it must start with lowercase letter and contain only alphanumeric letters`,
validator: (name) =>
!name.match(
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
),
},
{
message: t`The name is not available`,
validator: (name) => RESERVED_METADATA_NAME_KEYWORDS.includes(name),
},
];
@@ -42,7 +42,7 @@ const coreObjectNames = [
'userWorkspaceRoles',
];
const reservedKeywords = [
export const RESERVED_METADATA_NAME_KEYWORDS = [
...coreObjectNames,
'plan',
'plans',
@@ -64,14 +64,12 @@ const reservedKeywords = [
'index',
'relation',
'relations',
'position',
'positions',
];
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
name: string,
) => {
if (reservedKeywords.includes(name)) {
if (RESERVED_METADATA_NAME_KEYWORDS.includes(name)) {
throw new InvalidMetadataException(
`The name "${name}" is not available`,
InvalidMetadataExceptionCode.RESERVED_KEYWORD,
@@ -5,7 +5,7 @@ import {
InvalidMetadataExceptionCode,
} from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata.exception';
const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
export const STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX =
/^[a-z][a-zA-Z0-9]*$/;
export const validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow =
@@ -1,21 +0,0 @@
import {
FieldMetadataException,
FieldMetadataExceptionCode,
} from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-or-throw.utils';
export const validateMetadataName = (
name: string,
): undefined | FieldMetadataException => {
try {
validateMetadataNameOrThrow(name);
} catch (error) {
return new FieldMetadataException(
error.message,
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
{
userFriendlyMessage: error.userFriendlyMessage,
},
);
}
};
@@ -1,6 +1,7 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { type FlatObjectMetadataMaps } from 'src/engine/metadata-modules/flat-object-metadata-maps/types/flat-object-metadata-maps.type';
import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
@@ -11,18 +12,18 @@ type ValidateNoOtherObjectWithSameNameExistsOrThrowsParams = {
objectMetadataNameSingular: string;
objectMetadataNamePlural: string;
existingObjectMetadataId?: string;
objectMetadataMaps: ObjectMetadataMaps;
objectMetadataMaps: ObjectMetadataMaps | FlatObjectMetadataMaps;
};
export const validatesNoOtherObjectWithSameNameExistsOrThrows = ({
objectMetadataNameSingular,
objectMetadataNamePlural,
existingObjectMetadataId,
export const doesOtherObjectWithSameNameExists = ({
objectMetadataMaps,
}: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams) => {
const objectAlreadyExists = Object.values(objectMetadataMaps.byId)
objectMetadataNamePlural,
objectMetadataNameSingular,
existingObjectMetadataId,
}: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams) =>
Object.values(objectMetadataMaps.byId)
.filter(isDefined)
.find(
.some(
(objectMetadata) =>
(objectMetadata.nameSingular === objectMetadataNameSingular ||
objectMetadata.namePlural === objectMetadataNamePlural ||
@@ -31,6 +32,11 @@ export const validatesNoOtherObjectWithSameNameExistsOrThrows = ({
objectMetadata.id !== existingObjectMetadataId,
);
export const validatesNoOtherObjectWithSameNameExistsOrThrows = (
args: ValidateNoOtherObjectWithSameNameExistsOrThrowsParams,
) => {
const objectAlreadyExists = doesOtherObjectWithSameNameExists(args);
if (objectAlreadyExists) {
throw new ObjectMetadataException(
'Object already exists',