[BUGFIX] ObjectMetadata item server validation (#10699)
# Introduction This PR contains several SNAPSHOT files explaining big + While refactoring the Object Model settings page in https://github.com/twentyhq/twenty/pull/10653, encountered a critical issue when submitting either one or both names with `""` empty string hard corrupting a workspace. This motivate this PR reviewing server side validation I feel like we could share zod schema between front and back ## Refactored server validation What to expect from Names: - Plural and singular have to be different ( case insensitive and trimmed check ) - Contains only a-z A-Z and 0-9 - Follows camelCase - Is not empty => Is not too short ( 1 ) - Is not too long ( 63 ) - Is case insensitive( fooBar and fOoBar now rejected ) What to expect from Labels: - Plural and singular have to be different ( case insensitive and trimmed check ) - Is not empty => Is not too short ( 1 ) - Is not too long ( 63 ) - Is case insensitive ( fooBar and fOoBar now rejected ) close https://github.com/twentyhq/twenty/issues/10694 ## Creation integrations tests Created new integrations tests, following [EachTesting](https://jestjs.io/docs/api#testeachtablename-fn-timeout) pattern and uses snapshot to assert errors message. These tests cover several failing use cases and started to implement ones for the happy path but object metadata item deletion is currently broken unless I'm mistaken @Weiko is on it ## Notes - [ ] As we've added new validation rules towards names and labels we should scan db in order to standardize existing values using either a migration command or manual check - [ ] Will review in an other PR the update path, adding integrations tests and so on
This commit is contained in:
@ -3,8 +3,8 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
|
||||
import { RecordGqlOperationOrderBy } from '@/object-record/graphql/types/RecordGqlOperationOrderBy';
|
||||
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
|
||||
import { RecordSort } from '@/object-record/record-sort/types/RecordSort';
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
import { FieldMetadataType } from '~/generated-metadata/graphql';
|
||||
import { EachTestingContext } from '~/types/EachTestingContext';
|
||||
|
||||
const objectMetadataItemWithPositionField: ObjectMetadataItem = {
|
||||
id: 'object1',
|
||||
|
||||
@ -2,7 +2,7 @@ import {
|
||||
SettingsDataModelObjectAboutFormValues,
|
||||
settingsDataModelObjectAboutFormSchema,
|
||||
} from '@/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema';
|
||||
import { EachTestingContext } from '~/types/EachTestingContext';
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
|
||||
describe('settingsDataModelObjectAboutFormSchema', () => {
|
||||
const validInput: SettingsDataModelObjectAboutFormValues = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { EachTestingContext } from '~/types/EachTestingContext';
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
import { buildRecordFromKeysWithSameValue } from '~/utils/array/buildRecordFromKeysWithSameValue';
|
||||
|
||||
type BuildRecordFromKeysWithSameValueTestContext = EachTestingContext<{
|
||||
|
||||
@ -5,6 +5,9 @@ const isBillingEnabled = process.env.IS_BILLING_ENABLED === 'true';
|
||||
const tsConfig = require('./tsconfig.json');
|
||||
|
||||
const jestConfig: JestConfigWithTsJest = {
|
||||
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
|
||||
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
|
||||
prettierPath: null,
|
||||
silent: false,
|
||||
verbose: true,
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
const jestConfig = {
|
||||
// For more information please have a look to official docs https://jestjs.io/docs/configuration/#prettierpath-string
|
||||
// Prettier v3 will should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
|
||||
// Prettier v3 should be supported in jest v30 https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.1
|
||||
prettierPath: null,
|
||||
// to enable logs, comment out the following line
|
||||
silent: true,
|
||||
@ -39,6 +39,7 @@ const jestConfig = {
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^src/(.*)': '<rootDir>/src/$1',
|
||||
'^test/(.*)': '<rootDir>/test/$1',
|
||||
},
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/dist'],
|
||||
|
||||
@ -41,12 +41,9 @@ import {
|
||||
RelationMetadataEntity,
|
||||
RelationMetadataType,
|
||||
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.entity';
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception';
|
||||
import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception';
|
||||
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
|
||||
import { validateMetadataNameValidityOrThrow as validateFieldNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
|
||||
import {
|
||||
@ -62,6 +59,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
import { ViewService } from 'src/modules/view/services/view.service';
|
||||
import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity';
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
import { FieldMetadataValidationService } from './field-metadata-validation.service';
|
||||
import { FieldMetadataEntity } from './field-metadata.entity';
|
||||
@ -540,30 +538,32 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
|
||||
): T {
|
||||
if (fieldMetadataInput.name) {
|
||||
try {
|
||||
validateFieldNameValidityOrThrow(fieldMetadataInput.name);
|
||||
validateMetadataNameOrThrow(fieldMetadataInput.name);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new FieldMetadataException(
|
||||
error.message,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
validateFieldNameAvailabilityOrThrow(
|
||||
fieldMetadataInput.name,
|
||||
objectMetadata,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStringException) {
|
||||
throw new FieldMetadataException(
|
||||
`Characters used in name "${fieldMetadataInput.name}" are not supported`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
} else if (error instanceof NameTooLongException) {
|
||||
throw new FieldMetadataException(
|
||||
`Name "${fieldMetadataInput.name}" exceeds 63 characters`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
} else if (error instanceof NameNotAvailableException) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new FieldMetadataException(
|
||||
`Name "${fieldMetadataInput.name}" is not available, check that it is not duplicating another field's name.`,
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -26,9 +26,10 @@ import { ObjectMetadataRelatedRecordsService } from 'src/engine/metadata-modules
|
||||
import { ObjectMetadataRelationService } from 'src/engine/metadata-modules/object-metadata/services/object-metadata-relation.service';
|
||||
import { buildDefaultFieldsForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-fields-for-custom-object.util';
|
||||
import {
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow,
|
||||
validateNameAndLabelAreSyncOrThrow,
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow,
|
||||
validateObjectMetadataInputOrThrow,
|
||||
validateObjectMetadataInputLabelsOrThrow,
|
||||
validateObjectMetadataInputNamesOrThrow,
|
||||
} from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
import { RemoteTableRelationsService } from 'src/engine/metadata-modules/remote-server/remote-table/remote-table-relations/remote-table-relations.service';
|
||||
import { SearchService } from 'src/engine/metadata-modules/search/search.service';
|
||||
@ -88,12 +89,24 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectMetadataInput.workspaceId,
|
||||
);
|
||||
|
||||
validateObjectMetadataInputOrThrow(objectMetadataInput);
|
||||
validateObjectMetadataInputNamesOrThrow(objectMetadataInput);
|
||||
validateObjectMetadataInputLabelsOrThrow(objectMetadataInput);
|
||||
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow(
|
||||
objectMetadataInput.nameSingular,
|
||||
objectMetadataInput.namePlural,
|
||||
);
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
objectMetadataInput.nameSingular,
|
||||
objectMetadataInput.namePlural,
|
||||
],
|
||||
message: 'The singular and plural names cannot be the same for an object',
|
||||
});
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
objectMetadataInput.labelPlural,
|
||||
objectMetadataInput.labelSingular,
|
||||
],
|
||||
message:
|
||||
'The singular and plural labels cannot be the same for an object',
|
||||
});
|
||||
|
||||
if (objectMetadataInput.isLabelSyncedWithName === true) {
|
||||
validateNameAndLabelAreSyncOrThrow(
|
||||
@ -199,7 +212,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
input: UpdateOneObjectInput,
|
||||
workspaceId: string,
|
||||
): Promise<ObjectMetadataEntity> {
|
||||
validateObjectMetadataInputOrThrow(input.update);
|
||||
validateObjectMetadataInputNamesOrThrow(input.update);
|
||||
|
||||
const existingObjectMetadata = await this.objectMetadataRepository.findOne({
|
||||
where: { id: input.id, workspaceId: workspaceId },
|
||||
@ -242,10 +255,14 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
isDefined(input.update.nameSingular) ||
|
||||
isDefined(input.update.namePlural)
|
||||
) {
|
||||
validateNameSingularAndNamePluralAreDifferentOrThrow(
|
||||
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
|
||||
existingObjectMetadataCombinedWithUpdateInput.namePlural,
|
||||
);
|
||||
validateLowerCasedAndTrimmedStringsAreDifferentOrThrow({
|
||||
inputs: [
|
||||
existingObjectMetadataCombinedWithUpdateInput.nameSingular,
|
||||
existingObjectMetadataCombinedWithUpdateInput.namePlural,
|
||||
],
|
||||
message:
|
||||
'The singular and plural names cannot be the same for an object',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedObject = await super.updateOne(input.id, input.update);
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when name exceeds maximum length 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when namePlural is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`validateObjectMetadataInputOrThrow should fail when nameSingular is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
@ -1,85 +1,57 @@
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input';
|
||||
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
import { validateObjectMetadataInputOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
import { validateObjectMetadataInputNamesOrThrow } from 'src/engine/metadata-modules/object-metadata/utils/validate-object-metadata-input.util';
|
||||
|
||||
const validObjectInput: UpdateObjectPayload = {
|
||||
labelPlural: 'Car',
|
||||
labelSingular: 'Cars',
|
||||
nameSingular: 'car',
|
||||
namePlural: 'cars',
|
||||
};
|
||||
type ValidateObjectNameTestingContext = EachTestingContext<
|
||||
Partial<UpdateObjectPayload>
|
||||
>;
|
||||
const validateObjectMetadataTestCases: ValidateObjectNameTestingContext[] = [
|
||||
{
|
||||
title: 'when nameSingular has invalid characters',
|
||||
context: { nameSingular: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural has invalid characters',
|
||||
context: { namePlural: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is a reserved keyword',
|
||||
context: { nameSingular: 'user' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is a reserved keyword',
|
||||
context: { namePlural: 'users' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is not camelCased',
|
||||
context: { nameSingular: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is not camelCased',
|
||||
context: { namePlural: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is an empty string',
|
||||
context: { namePlural: '' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is an empty string',
|
||||
context: { nameSingular: '' },
|
||||
},
|
||||
{
|
||||
title: 'when name exceeds maximum length',
|
||||
context: { nameSingular: 'a'.repeat(64) },
|
||||
},
|
||||
];
|
||||
|
||||
const reservedKeyword = 'user';
|
||||
|
||||
describe('validateObjectName', () => {
|
||||
it('should not throw if names are valid', () => {
|
||||
describe('validateObjectMetadataInputOrThrow should fail', () => {
|
||||
it.each(validateObjectMetadataTestCases)('$title', ({ context }) => {
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(validObjectInput),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw is nameSingular has invalid characters', () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: 'μ',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw is namePlural has invalid characters', () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: 'μ',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if nameSingular is a reserved keyword', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: reservedKeyword,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if namePlural is a reserved keyword', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: reservedKeyword,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if nameSingular is not camelCased', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
nameSingular: 'notACamelCase1a',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('should throw if namePlural is a not camelCased', async () => {
|
||||
const invalidObjectInput = {
|
||||
...validObjectInput,
|
||||
namePlural: 'notACamelCase1b',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateObjectMetadataInputOrThrow(invalidObjectInput),
|
||||
).toThrow();
|
||||
validateObjectMetadataInputNamesOrThrow(
|
||||
getMockCreateObjectInput(context),
|
||||
),
|
||||
).toThrowErrorMatchingSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { slugify } from 'transliteration';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
import { UpdateObjectPayload } from 'src/engine/metadata-modules/object-metadata/dtos/update-object.input';
|
||||
@ -6,138 +7,72 @@ import {
|
||||
ObjectMetadataException,
|
||||
ObjectMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
|
||||
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
import { camelCase } from 'src/utils/camel-case';
|
||||
|
||||
const coreObjectNames = [
|
||||
'approvedAccessDomain',
|
||||
'approvedAccessDomains',
|
||||
'appToken',
|
||||
'appTokens',
|
||||
'billingCustomer',
|
||||
'billingCustomers',
|
||||
'billingEntitlement',
|
||||
'billingEntitlements',
|
||||
'billingMeter',
|
||||
'billingMeters',
|
||||
'billingProduct',
|
||||
'billingProducts',
|
||||
'billingSubscription',
|
||||
'billingSubscriptions',
|
||||
'billingSubscriptionItem',
|
||||
'billingSubscriptionItems',
|
||||
'featureFlag',
|
||||
'featureFlags',
|
||||
'keyValuePair',
|
||||
'keyValuePairs',
|
||||
'postgresCredential',
|
||||
'postgresCredentials',
|
||||
'twoFactorMethod',
|
||||
'twoFactorMethods',
|
||||
'user',
|
||||
'users',
|
||||
'userWorkspace',
|
||||
'userWorkspaces',
|
||||
'workspace',
|
||||
'workspaces',
|
||||
|
||||
'role',
|
||||
'roles',
|
||||
'userWorkspaceRole',
|
||||
'userWorkspaceRoles',
|
||||
];
|
||||
|
||||
const reservedKeywords = [
|
||||
...coreObjectNames,
|
||||
'event',
|
||||
'events',
|
||||
'field',
|
||||
'fields',
|
||||
'link',
|
||||
'links',
|
||||
'currency',
|
||||
'currencies',
|
||||
'fullName',
|
||||
'fullNames',
|
||||
'address',
|
||||
'addresses',
|
||||
'type',
|
||||
'types',
|
||||
'object',
|
||||
'objects',
|
||||
'index',
|
||||
'relation',
|
||||
'relations',
|
||||
];
|
||||
|
||||
export const validateObjectMetadataInputOrThrow = <
|
||||
export const validateObjectMetadataInputNamesOrThrow = <
|
||||
T extends UpdateObjectPayload | CreateObjectInput,
|
||||
>(
|
||||
objectMetadataInput: T,
|
||||
): void => {
|
||||
validateNameCamelCasedOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameCamelCasedOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameCharactersOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameCharactersOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.nameSingular);
|
||||
validateNameIsNotReservedKeywordOrThrow(objectMetadataInput.namePlural);
|
||||
|
||||
validateNameIsNotTooLongThrow(objectMetadataInput.nameSingular);
|
||||
validateNameIsNotTooLongThrow(objectMetadataInput.namePlural);
|
||||
};
|
||||
|
||||
const validateNameIsNotReservedKeywordOrThrow = (name?: string) => {
|
||||
if (name) {
|
||||
if (reservedKeywords.includes(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`The name "${name}" is not available`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
>({
|
||||
namePlural,
|
||||
nameSingular,
|
||||
}: T): void =>
|
||||
[namePlural, nameSingular].forEach((name) => {
|
||||
if (!isDefined(name)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
validateObjectMetadataInputNameOrThrow(name);
|
||||
});
|
||||
|
||||
const validateNameCamelCasedOrThrow = (name?: string) => {
|
||||
if (name && name !== camelCase(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`Name should be in camelCase: ${name}`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateNameIsNotTooLongThrow = (name?: string) => {
|
||||
if (name && exceedsDatabaseIdentifierMaximumLength(name)) {
|
||||
throw new ObjectMetadataException(
|
||||
`Name exceeds 63 characters: ${name}`,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const validateNameCharactersOrThrow = (name?: string) => {
|
||||
export const validateObjectMetadataInputNameOrThrow = (name: string): void => {
|
||||
try {
|
||||
if (name) {
|
||||
validateMetadataNameValidityOrThrow(name);
|
||||
}
|
||||
validateMetadataNameOrThrow(name);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStringException) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new ObjectMetadataException(
|
||||
`Characters used in name "${name}" are not supported`,
|
||||
error.message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const validateObjectMetadataInputLabelsOrThrow = <
|
||||
T extends CreateObjectInput,
|
||||
>({
|
||||
labelPlural,
|
||||
labelSingular,
|
||||
}: T): void =>
|
||||
[labelPlural, labelSingular].forEach((label) =>
|
||||
validateObjectMetadataInputLabelOrThrow(label),
|
||||
);
|
||||
|
||||
const validateObjectMetadataInputLabelOrThrow = (name: string): void => {
|
||||
const validators = [
|
||||
validateMetadataNameIsNotTooShortOrThrow,
|
||||
validateMetadataNameIsNotTooLongOrThrow,
|
||||
];
|
||||
|
||||
try {
|
||||
validators.forEach((validator) => validator(name.trim()));
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidMetadataNameException) {
|
||||
throw new ObjectMetadataException(
|
||||
error.message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const computeMetadataNameFromLabel = (label: string): string => {
|
||||
if (!label) {
|
||||
if (!isDefined(label)) {
|
||||
throw new ObjectMetadataException(
|
||||
'Label is required',
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
@ -180,13 +115,17 @@ export const validateNameAndLabelAreSyncOrThrow = (
|
||||
}
|
||||
};
|
||||
|
||||
export const validateNameSingularAndNamePluralAreDifferentOrThrow = (
|
||||
nameSingular: string,
|
||||
namePlural: string,
|
||||
) => {
|
||||
if (nameSingular === namePlural) {
|
||||
type ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs = {
|
||||
inputs: [string, string];
|
||||
message: string;
|
||||
};
|
||||
export const validateLowerCasedAndTrimmedStringsAreDifferentOrThrow = ({
|
||||
message,
|
||||
inputs: [firstString, secondString],
|
||||
}: ValidateLowerCasedAndTrimmedStringAreDifferentOrThrowArgs) => {
|
||||
if (firstString.trim().toLowerCase() === secondString.trim().toLowerCase()) {
|
||||
throw new ObjectMetadataException(
|
||||
'The singular and plural name cannot be the same for an object',
|
||||
message,
|
||||
ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
@ -19,9 +19,8 @@ import {
|
||||
RelationMetadataException,
|
||||
RelationMetadataExceptionCode,
|
||||
} from 'src/engine/metadata-modules/relation-metadata/relation-metadata.exception';
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
|
||||
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
import { WorkspaceMetadataVersionService } from 'src/engine/metadata-modules/workspace-metadata-version/services/workspace-metadata-version.service';
|
||||
import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';
|
||||
import {
|
||||
@ -34,6 +33,7 @@ import { computeObjectTargetTable } from 'src/engine/utils/compute-object-target
|
||||
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
|
||||
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service';
|
||||
import { BASE_OBJECT_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids';
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
import {
|
||||
RelationMetadataEntity,
|
||||
@ -67,17 +67,15 @@ export class RelationMetadataService extends TypeOrmQueryService<RelationMetadat
|
||||
);
|
||||
|
||||
try {
|
||||
validateMetadataNameValidityOrThrow(relationMetadataInput.fromName);
|
||||
validateMetadataNameValidityOrThrow(relationMetadataInput.toName);
|
||||
validateMetadataNameOrThrow(relationMetadataInput.fromName);
|
||||
validateMetadataNameOrThrow(relationMetadataInput.toName);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidStringException) {
|
||||
if (error instanceof InvalidMetadataNameException)
|
||||
throw new RelationMetadataException(
|
||||
`Characters used in name "${relationMetadataInput.fromName}" or "${relationMetadataInput.toName}" are not supported`,
|
||||
error.message,
|
||||
RelationMetadataExceptionCode.INVALID_RELATION_INPUT,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.validateCreateRelationMetadataInput(
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`validateFieldNameAvailabilityOrThrow throws error with ACTOR suffixes 1`] = `"Name "fieldActorName" is not available"`;
|
||||
|
||||
exports[`validateFieldNameAvailabilityOrThrow throws error with ADDRESS suffixes 1`] = `"Name "fieldAddressAddressStreet1" is not available"`;
|
||||
|
||||
exports[`validateFieldNameAvailabilityOrThrow throws error with CURRENCY suffixes 1`] = `"Name "fieldCurrencyAmountMicros" is not available"`;
|
||||
|
||||
exports[`validateFieldNameAvailabilityOrThrow throws error with FULL_NAME suffixes 1`] = `"Name "fieldFullNameFirstName" is not available"`;
|
||||
|
||||
exports[`validateFieldNameAvailabilityOrThrow throws error with LINKS suffixes 1`] = `"Name "fieldLinksPrimaryLinkLabel" is not available"`;
|
||||
@ -0,0 +1,17 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`validateMetadataNameOrThrow throw error when string is not in camel case 1`] = `"Name should be in camelCase: TestName"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when starts with digits 1`] = `"Name should be in camelCase: 123string"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string has non latin characters 1`] = `"String "בְרִבְרִ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string has spaces 1`] = `"Name should be in camelCase: name with spaces"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string is a reserved word 1`] = `"The name "role" is not available"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string is above 63 characters 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string is empty 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`validateMetadataNameOrThrow throws error when string starts with capital letter 1`] = `"Name should be in camelCase: StringStartingWithCapitalLetter"`;
|
||||
@ -1,3 +1,5 @@
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
|
||||
import {
|
||||
FIELD_ACTOR_MOCK_NAME,
|
||||
FIELD_ADDRESS_MOCK_NAME,
|
||||
@ -6,55 +8,69 @@ import {
|
||||
FIELD_LINKS_MOCK_NAME,
|
||||
objectMetadataItemMock,
|
||||
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
|
||||
import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception';
|
||||
import { validateFieldNameAvailabilityOrThrow } from 'src/engine/metadata-modules/utils/validate-field-name-availability.utils';
|
||||
|
||||
type ValidateFieldNameAvailabilityTestContext = EachTestingContext<{
|
||||
input: string;
|
||||
shouldNotThrow?: true;
|
||||
}>;
|
||||
|
||||
const validateFieldNameAvailabilityTestCases: ValidateFieldNameAvailabilityTestContext[] =
|
||||
[
|
||||
{
|
||||
title: 'does not throw if name is not reserved',
|
||||
context: {
|
||||
input: 'testName',
|
||||
shouldNotThrow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error with LINKS suffixes',
|
||||
context: {
|
||||
input: `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error with CURRENCY suffixes',
|
||||
context: {
|
||||
input: `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error with FULL_NAME suffixes',
|
||||
context: {
|
||||
input: `${FIELD_FULL_NAME_MOCK_NAME}FirstName`,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error with ACTOR suffixes',
|
||||
context: {
|
||||
input: `${FIELD_ACTOR_MOCK_NAME}Name`,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error with ADDRESS suffixes',
|
||||
context: {
|
||||
input: `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('validateFieldNameAvailabilityOrThrow', () => {
|
||||
const objectMetadata = objectMetadataItemMock;
|
||||
|
||||
it('does not throw if name is not reserved', () => {
|
||||
const name = 'testName';
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
describe('error cases', () => {
|
||||
it('throws error with LINKS suffixes', () => {
|
||||
const name = `${FIELD_LINKS_MOCK_NAME}PrimaryLinkLabel`;
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).toThrow(NameNotAvailableException);
|
||||
});
|
||||
it('throws error with CURRENCY suffixes', () => {
|
||||
const name = `${FIELD_CURRENCY_MOCK_NAME}AmountMicros`;
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).toThrow(NameNotAvailableException);
|
||||
});
|
||||
it('throws error with FULL_NAME suffixes', () => {
|
||||
const name = `${FIELD_FULL_NAME_MOCK_NAME}FirstName`;
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).toThrow(NameNotAvailableException);
|
||||
});
|
||||
it('throws error with ACTOR suffixes', () => {
|
||||
const name = `${FIELD_ACTOR_MOCK_NAME}Name`;
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).toThrow(NameNotAvailableException);
|
||||
});
|
||||
it('throws error with ADDRESS suffixes', () => {
|
||||
const name = `${FIELD_ADDRESS_MOCK_NAME}AddressStreet1`;
|
||||
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(name, objectMetadata),
|
||||
).toThrow(NameNotAvailableException);
|
||||
});
|
||||
});
|
||||
it.each(validateFieldNameAvailabilityTestCases)(
|
||||
'$title',
|
||||
({ context: { input, shouldNotThrow } }) => {
|
||||
if (shouldNotThrow) {
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(input, objectMetadata),
|
||||
).not.toThrow();
|
||||
} else {
|
||||
expect(() =>
|
||||
validateFieldNameAvailabilityOrThrow(input, objectMetadata),
|
||||
).toThrowErrorMatchingSnapshot();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception';
|
||||
import { validateMetadataNameValidityOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-validity.utils';
|
||||
|
||||
describe('validateMetadataNameValidityOrThrow', () => {
|
||||
it('does not throw if string is valid', () => {
|
||||
const input = 'testName';
|
||||
|
||||
expect(validateMetadataNameValidityOrThrow(input)).not.toThrow;
|
||||
});
|
||||
it('throws error if string has spaces', () => {
|
||||
const input = 'name with spaces';
|
||||
|
||||
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
|
||||
InvalidStringException,
|
||||
);
|
||||
});
|
||||
it('throws error if string starts with capital letter', () => {
|
||||
const input = 'StringStartingWithCapitalLetter';
|
||||
|
||||
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
|
||||
InvalidStringException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error if string has non latin characters', () => {
|
||||
const input = 'בְרִבְרִ';
|
||||
|
||||
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
|
||||
InvalidStringException,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws error if starts with digits', () => {
|
||||
const input = '123string';
|
||||
|
||||
expect(() => validateMetadataNameValidityOrThrow(input)).toThrow(
|
||||
InvalidStringException,
|
||||
);
|
||||
});
|
||||
it('does not throw if string is less than 63 characters', () => {
|
||||
const inputWith63Characters =
|
||||
'thisIsAstringWithSixtyThreeCharacters11111111111111111111111111';
|
||||
|
||||
expect(validateMetadataNameValidityOrThrow(inputWith63Characters)).not
|
||||
.toThrow;
|
||||
});
|
||||
it('throws error if string is above 63 characters', () => {
|
||||
const inputWith64Characters =
|
||||
'thisIsAstringWithSixtyFourCharacters1111111111111111111111111111';
|
||||
|
||||
expect(() =>
|
||||
validateMetadataNameValidityOrThrow(inputWith64Characters),
|
||||
).toThrow(NameTooLongException);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,88 @@
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
|
||||
import { validateMetadataNameOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name.utils';
|
||||
|
||||
type ValidateMetadataNameTestContext = EachTestingContext<{
|
||||
input: string;
|
||||
shouldNotThrow?: true;
|
||||
}>;
|
||||
|
||||
const validateMetadataNameTestCases: ValidateMetadataNameTestContext[] = [
|
||||
{
|
||||
title: 'validates when string is valid',
|
||||
context: {
|
||||
input: 'testName',
|
||||
shouldNotThrow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throw error when string is not in camel case',
|
||||
context: {
|
||||
input: 'TestName',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string has spaces',
|
||||
context: {
|
||||
input: 'name with spaces',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string is a reserved word',
|
||||
context: {
|
||||
input: 'role',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string starts with capital letter',
|
||||
context: {
|
||||
input: 'StringStartingWithCapitalLetter',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string has non latin characters',
|
||||
context: {
|
||||
input: 'בְרִבְרִ',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when starts with digits',
|
||||
context: {
|
||||
input: '123string',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'validates when string is less than 63 characters',
|
||||
context: {
|
||||
input: 'a'.repeat(63),
|
||||
shouldNotThrow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string is above 63 characters',
|
||||
context: {
|
||||
input: 'a'.repeat(64),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'throws error when string is empty',
|
||||
context: {
|
||||
input: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('validateMetadataNameOrThrow', () => {
|
||||
it.each(validateMetadataNameTestCases)(
|
||||
'$title',
|
||||
({ context: { input, shouldNotThrow } }) => {
|
||||
if (shouldNotThrow) {
|
||||
expect(() => validateMetadataNameOrThrow(input)).not.toThrow();
|
||||
} else {
|
||||
expect(() =>
|
||||
validateMetadataNameOrThrow(input),
|
||||
).toThrowErrorMatchingSnapshot();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export const IDENTIFIER_MIN_CHAR_LENGTH = 1;
|
||||
@ -0,0 +1,5 @@
|
||||
export class InvalidMetadataNameException extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export class InvalidStringException extends Error {
|
||||
constructor(string: string) {
|
||||
const message = `String "${string}" is not valid`;
|
||||
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export class NameNotAvailableException extends Error {
|
||||
constructor(name: string) {
|
||||
const message = `Name "${name}" is not available`;
|
||||
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
export class NameTooLongException extends Error {
|
||||
constructor(string: string) {
|
||||
const message = `String "${string}" exceeds 63 characters limit`;
|
||||
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants';
|
||||
import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants';
|
||||
|
||||
export const exceedsDatabaseIdentifierMaximumLength = (string: string) => {
|
||||
return string.length > IDENTIFIER_MAX_CHAR_LENGTH;
|
||||
};
|
||||
export const exceedsDatabaseIdentifierMaximumLength = (string: string) =>
|
||||
string.length > IDENTIFIER_MAX_CHAR_LENGTH;
|
||||
|
||||
export const beneathDatabaseIdentifierMinimumLength = (string: string) =>
|
||||
string.length < IDENTIFIER_MIN_CHAR_LENGTH;
|
||||
|
||||
@ -2,7 +2,7 @@ import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-meta
|
||||
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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { NameNotAvailableException } from 'src/engine/metadata-modules/utils/exceptions/name-not-available.exception';
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
const getReservedCompositeFieldNames = (
|
||||
objectMetadata: ObjectMetadataEntity,
|
||||
@ -33,10 +33,10 @@ export const validateFieldNameAvailabilityOrThrow = (
|
||||
getReservedCompositeFieldNames(objectMetadata);
|
||||
|
||||
if (objectMetadata.fields.some((field) => field.name === name)) {
|
||||
throw new NameNotAvailableException(name);
|
||||
throw new InvalidMetadataNameException(`Name "${name}" is not available`);
|
||||
}
|
||||
|
||||
if (reservedCompositeFieldsNames.includes(name)) {
|
||||
throw new NameNotAvailableException(name);
|
||||
throw new InvalidMetadataNameException(`Name "${name}" is not available`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
import camelCase from 'lodash.camelcase';
|
||||
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
export const validateMetadataNameIsCamelCaseOrThrow = (name: string) => {
|
||||
if (name !== camelCase(name)) {
|
||||
throw new InvalidMetadataNameException(
|
||||
`Name should be in camelCase: ${name}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
const coreObjectNames = [
|
||||
'approvedAccessDomain',
|
||||
'approvedAccessDomains',
|
||||
'appToken',
|
||||
'appTokens',
|
||||
'billingCustomer',
|
||||
'billingCustomers',
|
||||
'billingEntitlement',
|
||||
'billingEntitlements',
|
||||
'billingMeter',
|
||||
'billingMeters',
|
||||
'billingProduct',
|
||||
'billingProducts',
|
||||
'billingSubscription',
|
||||
'billingSubscriptions',
|
||||
'billingSubscriptionItem',
|
||||
'billingSubscriptionItems',
|
||||
'featureFlag',
|
||||
'featureFlags',
|
||||
'keyValuePair',
|
||||
'keyValuePairs',
|
||||
'postgresCredential',
|
||||
'postgresCredentials',
|
||||
'twoFactorMethod',
|
||||
'twoFactorMethods',
|
||||
'user',
|
||||
'users',
|
||||
'userWorkspace',
|
||||
'userWorkspaces',
|
||||
'workspace',
|
||||
'workspaces',
|
||||
'role',
|
||||
'roles',
|
||||
'userWorkspaceRole',
|
||||
'userWorkspaceRoles',
|
||||
];
|
||||
|
||||
const reservedKeywords = [
|
||||
...coreObjectNames,
|
||||
'event',
|
||||
'events',
|
||||
'field',
|
||||
'fields',
|
||||
'link',
|
||||
'links',
|
||||
'currency',
|
||||
'currencies',
|
||||
'fullName',
|
||||
'fullNames',
|
||||
'address',
|
||||
'addresses',
|
||||
'type',
|
||||
'types',
|
||||
'object',
|
||||
'objects',
|
||||
'index',
|
||||
'relation',
|
||||
'relations',
|
||||
];
|
||||
|
||||
export const validateMetadataNameIsNotReservedKeywordOrThrow = (
|
||||
name: string,
|
||||
) => {
|
||||
if (reservedKeywords.includes(name)) {
|
||||
throw new InvalidMetadataNameException(
|
||||
`The name "${name}" is not available`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
|
||||
export const validateMetadataNameIsNotTooLongOrThrow = (name: string) => {
|
||||
if (exceedsDatabaseIdentifierMaximumLength(name)) {
|
||||
throw new InvalidMetadataNameException(
|
||||
`String "${name}" exceeds 63 characters limit`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
import { beneathDatabaseIdentifierMinimumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
|
||||
export const validateMetadataNameIsNotTooShortOrThrow = (name: string) => {
|
||||
if (beneathDatabaseIdentifierMinimumLength(name)) {
|
||||
throw new InvalidMetadataNameException(`Input is too short: "${name}"`);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { InvalidMetadataNameException } from 'src/engine/metadata-modules/utils/exceptions/invalid-metadata-name.exception';
|
||||
|
||||
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 =
|
||||
(name: string) => {
|
||||
if (
|
||||
!name.match(
|
||||
STARTS_WITH_LOWER_CASE_AND_CONTAINS_ONLY_CAPS_AND_LOWER_LETTERS_AND_NUMBER_STRING_REGEX,
|
||||
)
|
||||
) {
|
||||
throw new InvalidMetadataNameException(
|
||||
`String "${name}" is not valid: must start with lowercase letter and contain only alphanumeric letters`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -1,14 +0,0 @@
|
||||
import { InvalidStringException } from 'src/engine/metadata-modules/utils/exceptions/invalid-string.exception';
|
||||
import { NameTooLongException } from 'src/engine/metadata-modules/utils/exceptions/name-too-long.exception';
|
||||
import { exceedsDatabaseIdentifierMaximumLength } from 'src/engine/metadata-modules/utils/validate-database-identifier-length.utils';
|
||||
|
||||
const VALID_STRING_PATTERN = /^[a-z][a-zA-Z0-9]*$/;
|
||||
|
||||
export const validateMetadataNameValidityOrThrow = (name: string) => {
|
||||
if (!name.match(VALID_STRING_PATTERN)) {
|
||||
throw new InvalidStringException(name);
|
||||
}
|
||||
if (exceedsDatabaseIdentifierMaximumLength(name)) {
|
||||
throw new NameTooLongException(name);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { validateMetadataNameIsCamelCaseOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-camel-case.utils';
|
||||
import { validateMetadataNameIsNotReservedKeywordOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-reserved-keyword';
|
||||
import { validateMetadataNameIsNotTooLongOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-long.utils';
|
||||
import { validateMetadataNameIsNotTooShortOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-is-not-too-short.utils';
|
||||
import { validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow } from 'src/engine/metadata-modules/utils/validate-metadata-name-start-with-lowercase-letter-and-contain-digits-nor-letters.utils';
|
||||
|
||||
export const validateMetadataNameOrThrow = (name: string): void => {
|
||||
const validators = [
|
||||
validateMetadataNameIsNotTooLongOrThrow,
|
||||
validateMetadataNameIsNotTooShortOrThrow,
|
||||
validateMetadataNameIsCamelCaseOrThrow,
|
||||
validateMetadataNameStartWithLowercaseLetterAndContainDigitsNorLettersOrThrow,
|
||||
validateMetadataNameIsNotReservedKeywordOrThrow,
|
||||
];
|
||||
|
||||
validators.forEach((validator) => validator(name));
|
||||
};
|
||||
@ -0,0 +1,43 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Object metadata creation should fail when labelPlural contains only whitespace 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when labelPlural exceeds maximum length 1`] = `"String "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`Object metadata creation should fail when labelPlural is empty 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when labelSingular contains only whitespace 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when labelSingular exceeds maximum length 1`] = `"String "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`Object metadata creation should fail when labelSingular is empty 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when labels are identical 1`] = `"The singular and plural labels cannot be the same for an object"`;
|
||||
|
||||
exports[`Object metadata creation should fail when labels with whitespaces result to be identical 1`] = `"The singular and plural labels cannot be the same for an object"`;
|
||||
|
||||
exports[`Object metadata creation should fail when name exceeds maximum length 1`] = `"String "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" exceeds 63 characters limit"`;
|
||||
|
||||
exports[`Object metadata creation should fail when namePlural has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`Object metadata creation should fail when namePlural is a reserved keyword 1`] = `"The name "users" is not available"`;
|
||||
|
||||
exports[`Object metadata creation should fail when namePlural is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when namePlural is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular contains only one char and whitespaces 1`] = `"Name should be in camelCase: a a "`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular contains only whitespaces 1`] = `"Name should be in camelCase: "`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular has invalid characters 1`] = `"String "μ" is not valid: must start with lowercase letter and contain only alphanumeric letters"`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular is a reserved keyword 1`] = `"The name "user" is not available"`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular is an empty string 1`] = `"Input is too short: """`;
|
||||
|
||||
exports[`Object metadata creation should fail when nameSingular is not camelCased 1`] = `"Name should be in camelCase: Not_Camel_Case"`;
|
||||
|
||||
exports[`Object metadata creation should fail when names are identical 1`] = `"The singular and plural names cannot be the same for an object"`;
|
||||
|
||||
exports[`Object metadata creation should fail when names with whitespaces result to be identical 1`] = `"Name should be in camelCase: fooBar "`;
|
||||
@ -0,0 +1,137 @@
|
||||
import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input';
|
||||
import { performFailingObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-failing-object-metadata-creation';
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
type CreateObjectInputPayload = Omit<
|
||||
CreateObjectInput,
|
||||
'workspaceId' | 'dataSourceId'
|
||||
>;
|
||||
|
||||
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
|
||||
Partial<CreateObjectInputPayload>
|
||||
>[];
|
||||
const failingNamesCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext =
|
||||
[
|
||||
{
|
||||
title: 'when nameSingular has invalid characters',
|
||||
context: { nameSingular: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural has invalid characters',
|
||||
context: { namePlural: 'μ' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is a reserved keyword',
|
||||
context: { nameSingular: 'user' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is a reserved keyword',
|
||||
context: { namePlural: 'users' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is not camelCased',
|
||||
context: { nameSingular: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is not camelCased',
|
||||
context: { namePlural: 'Not_Camel_Case' },
|
||||
},
|
||||
{
|
||||
title: 'when namePlural is an empty string',
|
||||
context: { namePlural: '' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular is an empty string',
|
||||
context: { nameSingular: '' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular contains only whitespaces',
|
||||
context: { nameSingular: ' ' },
|
||||
},
|
||||
{
|
||||
title: 'when nameSingular contains only one char and whitespaces',
|
||||
context: { nameSingular: ' a a ' },
|
||||
},
|
||||
{
|
||||
title: 'when name exceeds maximum length',
|
||||
context: { nameSingular: 'a'.repeat(64) },
|
||||
},
|
||||
{
|
||||
title: 'when names are identical',
|
||||
context: {
|
||||
nameSingular: 'fooBar',
|
||||
namePlural: 'fooBar',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when names with whitespaces result to be identical',
|
||||
context: {
|
||||
nameSingular: ' fooBar ',
|
||||
namePlural: 'fooBar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const failingLabelsCreationTestsUseCase: CreateOneObjectMetadataItemTestingContext =
|
||||
[
|
||||
{
|
||||
title: 'when labelSingular is empty',
|
||||
context: { labelSingular: '' },
|
||||
},
|
||||
{
|
||||
title: 'when labelPlural is empty',
|
||||
context: { labelPlural: '' },
|
||||
},
|
||||
{
|
||||
title: 'when labelSingular exceeds maximum length',
|
||||
context: { labelSingular: 'A'.repeat(64) },
|
||||
},
|
||||
{
|
||||
title: 'when labelPlural exceeds maximum length',
|
||||
context: { labelPlural: 'A'.repeat(64) },
|
||||
},
|
||||
{
|
||||
title: 'when labelSingular contains only whitespace',
|
||||
context: { labelSingular: ' ' },
|
||||
},
|
||||
{
|
||||
title: 'when labelPlural contains only whitespace',
|
||||
context: { labelPlural: ' ' },
|
||||
},
|
||||
{
|
||||
title: 'when labels are identical',
|
||||
context: {
|
||||
labelPlural: 'fooBar',
|
||||
labelSingular: 'fooBar',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'when labels with whitespaces result to be identical',
|
||||
context: {
|
||||
labelPlural: ' fooBar ',
|
||||
labelSingular: 'fooBar',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const allTestsUseCases = [
|
||||
...failingNamesCreationTestsUseCase,
|
||||
...failingLabelsCreationTestsUseCase,
|
||||
];
|
||||
|
||||
describe('Object metadata creation should fail', () => {
|
||||
it.each(allTestsUseCases)('$title', async ({ context }) => {
|
||||
const errors = await performFailingObjectMetadataCreation(
|
||||
getMockCreateObjectInput(context),
|
||||
);
|
||||
|
||||
expect(errors.length).toBe(1);
|
||||
const firstError = errors[0];
|
||||
|
||||
expect(firstError.extensions.code).toBe(ErrorCode.BAD_USER_INPUT);
|
||||
expect(firstError.message).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import { deleteOneObjectMetadataItem } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { getMockCreateObjectInput } from 'test/integration/utils/object-metadata/generate-mock-create-object-metadata-input';
|
||||
import { performObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-object-metadata-creation';
|
||||
import { EachTestingContext } from 'twenty-shared';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
|
||||
type CreateObjectInputPayload = Omit<
|
||||
CreateObjectInput,
|
||||
'workspaceId' | 'dataSourceId'
|
||||
>;
|
||||
|
||||
type CreateOneObjectMetadataItemTestingContext = EachTestingContext<
|
||||
Partial<CreateObjectInputPayload>
|
||||
>[];
|
||||
const successfulObjectMetadataItemCreateOneUseCase: CreateOneObjectMetadataItemTestingContext =
|
||||
[
|
||||
{
|
||||
title: 'with basic input',
|
||||
context: {},
|
||||
},
|
||||
// TODO populate
|
||||
];
|
||||
|
||||
const allTestsUseCases = [...successfulObjectMetadataItemCreateOneUseCase];
|
||||
|
||||
describe('Object metadata creation should succeed', () => {
|
||||
it.each(allTestsUseCases)('$title', async ({ context }) => {
|
||||
const response = await performObjectMetadataCreation(
|
||||
getMockCreateObjectInput(context),
|
||||
);
|
||||
|
||||
expect(response.body.data.createOneObject.id).toBeDefined();
|
||||
await deleteOneObjectMetadataItem(
|
||||
response.body.data.createOneObject.id,
|
||||
).catch();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,15 @@
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
|
||||
// TODO would tend to use faker
|
||||
export const getMockCreateObjectInput = (
|
||||
overrides?: Partial<Omit<CreateObjectInput, 'workspaceId' | 'dataSourceId'>>,
|
||||
) => ({
|
||||
namePlural: 'listings',
|
||||
nameSingular: 'listing',
|
||||
labelPlural: 'Listings',
|
||||
labelSingular: 'Listing',
|
||||
description: 'Listing object',
|
||||
icon: 'IconListNumbers',
|
||||
isLabelSyncedWithName: false,
|
||||
...overrides,
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import { deleteOneObjectMetadataItem } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util';
|
||||
import { performObjectMetadataCreation } from 'test/integration/utils/object-metadata/perform-object-metadata-creation';
|
||||
import { isDefined } from 'twenty-shared';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
|
||||
export const performFailingObjectMetadataCreation = async (
|
||||
objectInput: Omit<CreateObjectInput, 'workspaceId' | 'dataSourceId'>,
|
||||
) => {
|
||||
const response = await performObjectMetadataCreation(objectInput);
|
||||
|
||||
if (isDefined(response.body.data)) {
|
||||
try {
|
||||
const createdId = response.body.data.createOneObject.id;
|
||||
|
||||
await deleteOneObjectMetadataItem(createdId);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
}
|
||||
expect(false).toEqual(
|
||||
'Object Metadata Item should have failed but did not',
|
||||
);
|
||||
}
|
||||
expect(response.body.errors.length).toBeGreaterThan(0);
|
||||
|
||||
return response.body.errors;
|
||||
};
|
||||
@ -0,0 +1,18 @@
|
||||
import { createOneObjectMetadataFactory } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
|
||||
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
|
||||
|
||||
export const performObjectMetadataCreation = async (
|
||||
args: Omit<CreateObjectInput, 'workspaceId' | 'dataSourceId'>,
|
||||
) => {
|
||||
const graphqlOperation = createOneObjectMetadataFactory({
|
||||
input: { object: args },
|
||||
gqlFields: `
|
||||
id
|
||||
nameSingular
|
||||
`,
|
||||
});
|
||||
|
||||
return await makeMetadataAPIRequest(graphqlOperation);
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
export * from './constants';
|
||||
export * from './i18n';
|
||||
export * from './testing';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
export * from './workspace';
|
||||
|
||||
1
packages/twenty-shared/src/testing/index.ts
Normal file
1
packages/twenty-shared/src/testing/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './types/EachTestingContext.type';
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './ConnectedAccountProvider';
|
||||
export * from './FieldMetadataType';
|
||||
export * from './IsExactly';
|
||||
|
||||
|
||||
@ -4,4 +4,3 @@ export * from './image';
|
||||
export * from './strings';
|
||||
export * from './url';
|
||||
export * from './validation';
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { APP_LOCALES } from 'src/i18n/constants/AppLocales';
|
||||
import { isValidLocale } from '../isValidLocale';
|
||||
import { APP_LOCALES } from 'src/constants/Locales';
|
||||
|
||||
describe('isValidLocale', () => {
|
||||
it('should return true for valid locales', () => {
|
||||
|
||||
Reference in New Issue
Block a user