From c4e55d08ff0ee0aa30a4697f9e7518e4b0c67ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 16 Mar 2026 18:07:34 +0100 Subject: [PATCH] fix: allow identical singular and plural labels for objects (#18678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Closes #18673 Some languages (e.g., German "Unternehmen") and even English words (sheep, deer, aircraft, series) have identical singular and plural forms. Twenty previously blocked saving when labels matched, making it impossible to correctly name objects in these cases. - **Labels** are purely display strings — removed the equality validation from both the frontend Zod schema and backend validator - **API names** (nameSingular/namePlural) must stay different since they generate distinct GraphQL resolvers (`findOne` vs `findMany`, `createOne` vs `createMany`, etc.) and REST endpoints — this validation is preserved - Added a shared `computeMetadataNamesFromLabels` util in `twenty-shared` that auto-appends `'s'` to the plural API name when both labels produce the same camelCase name (e.g., "Unternehmen" → `unternehmen` / `unternehmens`) - Both the frontend form and backend sync-check use the same shared util — single source of truth, no duplicated logic **No retroactive impact**: since the old code prevented identical labels from ever being saved, no existing workspace has `labelSingular === labelPlural`. ## Test plan - [x] New unit tests for `computeMetadataNamesFromLabels` (7 tests: standard labels, Sheep, Unternehmen, Aircraft, empty labels, different labels, applyCustomSuffix) - [x] Updated frontend schema validation tests (identical labels with different names now passes; identical names still fails) - [x] Updated backend integration test cases (removed identical-label failing cases) - [ ] Manual: create a new object with identical singular/plural labels (e.g. "Sheep" / "Sheep") — should save successfully with API names `sheep` / `sheeps` - [ ] Manual: verify existing objects with different labels still work unchanged Made with [Cursor](https://cursor.com) --- .../DatabaseIdentifierMaximumLength.ts | 1 - .../constants/FieldNameMaximumLength.ts | 4 +- .../constants/ObjectNameMaximumLength.ts | 4 +- .../constants/OptionValueMaximumLength.ts | 4 +- .../SettingsDataModelFieldIconLabelForm.tsx | 4 +- .../SettingsDataModelObjectAboutForm.tsx | 31 +- ...ataModelObjectAboutFormSchema.test.ts.snap | 24 +- ...ingsDataModelObjectAboutFormSchema.test.ts | 18 +- .../settingsDataModelObjectAboutFormSchema.ts | 18 +- .../utils/computeMetadataNamesFromLabels.ts | 15 + .../validate-enum-flat-field-metadata.util.ts | 2 +- .../validate-flat-field-metadata-name.util.ts | 6 +- ...-metadata-names-synced-with-labels.util.ts | 15 +- ...alidate-flat-object-metadata-label.util.ts | 15 +- ...validate-flat-object-metadata-name.util.ts | 6 +- .../identifier-max-char-length.constants.ts | 1 - ...lidate-database-identifier-length.utils.ts | 2 +- ...bject-metadata-v2.integration-spec.ts.snap | 1464 ----------------- ...ject-metadata-label-failing-tests-cases.ts | 14 - ...puteMetadataNamesFromLabelsOrThrow.test.ts | 96 ++ .../identifier-max-char-length.constant.ts | 2 + packages/twenty-shared/src/metadata/index.ts | 2 + ...etadata-names-from-labels-or-throw.util.ts | 28 + 23 files changed, 204 insertions(+), 1572 deletions(-) delete mode 100644 packages/twenty-front/src/modules/settings/data-model/constants/DatabaseIdentifierMaximumLength.ts create mode 100644 packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNamesFromLabels.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants.ts create mode 100644 packages/twenty-shared/src/metadata/__tests__/computeMetadataNamesFromLabelsOrThrow.test.ts create mode 100644 packages/twenty-shared/src/metadata/constants/identifier-max-char-length.constant.ts create mode 100644 packages/twenty-shared/src/metadata/utils/compute-metadata-names-from-labels-or-throw.util.ts diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/DatabaseIdentifierMaximumLength.ts b/packages/twenty-front/src/modules/settings/data-model/constants/DatabaseIdentifierMaximumLength.ts deleted file mode 100644 index d7ac7dd9a2..0000000000 --- a/packages/twenty-front/src/modules/settings/data-model/constants/DatabaseIdentifierMaximumLength.ts +++ /dev/null @@ -1 +0,0 @@ -export const DATABASE_IDENTIFIER_MAXIMUM_LENGTH = 63; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/FieldNameMaximumLength.ts b/packages/twenty-front/src/modules/settings/data-model/constants/FieldNameMaximumLength.ts index bd647b2cbe..6cd437b278 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/FieldNameMaximumLength.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/FieldNameMaximumLength.ts @@ -1,3 +1,3 @@ -import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; -export const FIELD_NAME_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH; +export const FIELD_NAME_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/ObjectNameMaximumLength.ts b/packages/twenty-front/src/modules/settings/data-model/constants/ObjectNameMaximumLength.ts index 6ba3692374..3a1e0106a4 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/ObjectNameMaximumLength.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/ObjectNameMaximumLength.ts @@ -1,3 +1,3 @@ -import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; -export const OBJECT_NAME_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH; +export const OBJECT_NAME_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH; diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/OptionValueMaximumLength.ts b/packages/twenty-front/src/modules/settings/data-model/constants/OptionValueMaximumLength.ts index 97f5cc7726..b0f95616fb 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/OptionValueMaximumLength.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/OptionValueMaximumLength.ts @@ -1,3 +1,3 @@ -import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; -export const OPTION_VALUE_MAXIMUM_LENGTH = DATABASE_IDENTIFIER_MAXIMUM_LENGTH; +export const OPTION_VALUE_MAXIMUM_LENGTH = IDENTIFIER_MAX_CHAR_LENGTH; diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx index 74d2b51446..dc15ebe11b 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldIconLabelForm.tsx @@ -8,7 +8,7 @@ import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fi import { AdvancedSettingsContentWrapperWithDot } from '@/settings/components/AdvancedSettingsContentWrapperWithDot'; import { AdvancedSettingsWrapper } from '@/settings/components/AdvancedSettingsWrapper'; import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle'; -import { DATABASE_IDENTIFIER_MAXIMUM_LENGTH } from '@/settings/data-model/constants/DatabaseIdentifierMaximumLength'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; import { getErrorMessageFromError } from '@/settings/data-model/fields/forms/utils/errorMessages'; import { IconPicker } from '@/ui/input/components/IconPicker'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; @@ -202,7 +202,7 @@ export const SettingsDataModelFieldIconLabelForm = ({ readOnly={readonly} disabled={!isNameEditEnabled} fullWidth - maxLength={DATABASE_IDENTIFIER_MAXIMUM_LENGTH} + maxLength={IDENTIFIER_MAX_CHAR_LENGTH} RightIcon={() => apiNameTooltipText && ( <> diff --git a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx index a09ffde20c..acc356a15a 100644 --- a/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/objects/forms/components/SettingsDataModelObjectAboutForm.tsx @@ -25,7 +25,7 @@ import { Card } from 'twenty-ui/layout'; import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants'; import { type StringKeyOf } from 'type-fest'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; -import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel'; +import { computeMetadataNamesFromLabels } from '~/pages/settings/data-model/utils/computeMetadataNamesFromLabels'; type SettingsDataModelObjectAboutFormProps = { disableEdition?: boolean; @@ -146,25 +146,24 @@ export const SettingsDataModelObjectAboutForm = ({ shouldValidate: true, }); if (isLabelSyncedWithName) { - fillNamePluralFromLabelPlural(labelPluralFromSingularLabel); + fillNamesFromLabels(labelSingular, labelPluralFromSingularLabel); } }; - const fillNameSingularFromLabelSingular = ( - labelSingular: string | undefined, + const fillNamesFromLabels = ( + currentLabelSingular: string, + currentLabelPlural: string, ) => { - if (!isDefined(labelSingular)) return; + const { nameSingular, namePlural } = computeMetadataNamesFromLabels( + currentLabelSingular, + currentLabelPlural, + ); - setValue('nameSingular', computeMetadataNameFromLabel(labelSingular), { + setValue('nameSingular', nameSingular, { shouldDirty: true, shouldValidate: true, }); - }; - - const fillNamePluralFromLabelPlural = (labelPlural: string | undefined) => { - if (!isDefined(labelPlural)) return; - - setValue('namePlural', computeMetadataNameFromLabel(labelPlural), { + setValue('namePlural', namePlural, { shouldDirty: true, shouldValidate: true, }); @@ -215,9 +214,6 @@ export const SettingsDataModelObjectAboutForm = ({ onChange={(value) => { onChange(capitalize(value)); fillLabelPlural(capitalize(value)); - if (isLabelSyncedWithName === true) { - fillNameSingularFromLabelSingular(value); - } }} onBlur={() => onNewDirtyField?.()} disabled={disableEdition} @@ -243,7 +239,7 @@ export const SettingsDataModelObjectAboutForm = ({ onChange={(value) => { onChange(capitalize(value)); if (isLabelSyncedWithName === true) { - fillNamePluralFromLabelPlural(value); + fillNamesFromLabels(labelSingular, capitalize(value)); } }} onBlur={() => onNewDirtyField?.()} @@ -411,8 +407,7 @@ export const SettingsDataModelObjectAboutForm = ({ value === true && (isCustomObject || isbeingCreatedObject) ) { - fillNamePluralFromLabelPlural(labelPlural); - fillNameSingularFromLabelSingular(labelSingular); + fillNamesFromLabels(labelSingular, labelPlural); } onNewDirtyField?.(); }} diff --git a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/__snapshots__/settingsDataModelObjectAboutFormSchema.test.ts.snap b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/__snapshots__/settingsDataModelObjectAboutFormSchema.test.ts.snap index 7e23594975..346d195422 100644 --- a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/__snapshots__/settingsDataModelObjectAboutFormSchema.test.ts.snap +++ b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/__snapshots__/settingsDataModelObjectAboutFormSchema.test.ts.snap @@ -34,20 +34,6 @@ exports[`settingsDataModelObjectAboutFormSchema fails when labels are empty stri "labelPlural" ], "message": "Too small: expected string to have >=1 characters" - }, - { - "code": "custom", - "message": "Singular and plural labels must be different", - "path": [ - "labelPlural" - ] - }, - { - "code": "custom", - "message": "Singular and plural labels must be different", - "path": [ - "labelSingular" - ] } ]] `; @@ -92,20 +78,20 @@ exports[`settingsDataModelObjectAboutFormSchema fails when required fields are m ]] `; -exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same 1`] = ` +exports[`settingsDataModelObjectAboutFormSchema fails when singular and plural labels are the same and names are the same 1`] = ` [ZodError: [ { "code": "custom", - "message": "Singular and plural labels must be different", + "message": "Singular and plural names must be different", "path": [ - "labelPlural" + "nameSingular" ] }, { "code": "custom", - "message": "Singular and plural labels must be different", + "message": "Singular and plural names must be different", "path": [ - "labelSingular" + "namePlural" ] } ]] diff --git a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts index 0eae15fc67..a7e282cbfe 100644 --- a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts +++ b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/__tests__/settingsDataModelObjectAboutFormSchema.test.ts @@ -50,6 +50,19 @@ describe('settingsDataModelObjectAboutFormSchema', () => { expectedSuccess: true, }, }, + { + title: 'validates input with identical labels but different names', + context: { + input: { + ...validInput, + labelSingular: 'Sheep', + labelPlural: 'Sheep', + nameSingular: 'sheep', + namePlural: 'sheeps', + }, + expectedSuccess: true, + }, + }, ]; const failsValidationTestsUseCase: EachTestingContext<{ @@ -90,12 +103,15 @@ describe('settingsDataModelObjectAboutFormSchema', () => { }, }, { - title: 'fails when singular and plural labels are the same', + title: + 'fails when singular and plural labels are the same and names are the same', context: { input: { ...validInput, labelPlural: 'Same Label', labelSingular: 'Same Label', + namePlural: 'sameName', + nameSingular: 'sameName', }, expectedSuccess: false, }, diff --git a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema.ts b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema.ts index 7b8badcb1e..afaa151e18 100644 --- a/packages/twenty-front/src/modules/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema.ts +++ b/packages/twenty-front/src/modules/settings/data-model/validation-schemas/settingsDataModelObjectAboutFormSchema.ts @@ -30,23 +30,7 @@ const settingsDataModelFormFieldsSchema = z.object({ export const settingsDataModelObjectAboutFormSchema = settingsDataModelFormFieldsSchema.superRefine( - ({ labelPlural, labelSingular, namePlural, nameSingular }, ctx) => { - const labelsAreDifferent = - labelPlural.trim().toLowerCase() !== labelSingular.trim().toLowerCase(); - if (!labelsAreDifferent) { - const labelFields: ReadonlyKeysArray = [ - 'labelPlural', - 'labelSingular', - ]; - labelFields.forEach((field) => - ctx.addIssue({ - code: 'custom', - message: t`Singular and plural labels must be different`, - path: [field], - }), - ); - } - + ({ namePlural, nameSingular }, ctx) => { const nameAreDifferent = nameSingular.toLowerCase() !== namePlural.toLowerCase(); if (!nameAreDifferent) { diff --git a/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNamesFromLabels.ts b/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNamesFromLabels.ts new file mode 100644 index 0000000000..563f5f7938 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/data-model/utils/computeMetadataNamesFromLabels.ts @@ -0,0 +1,15 @@ +import { computeMetadataNamesFromLabelsOrThrow } from 'twenty-shared/metadata'; + +export const computeMetadataNamesFromLabels = ( + labelSingular: string, + labelPlural: string, +): { nameSingular: string; namePlural: string } => { + try { + return computeMetadataNamesFromLabelsOrThrow({ + labelSingular, + labelPlural, + }); + } catch { + return { nameSingular: '', namePlural: '' }; + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util.ts index 273eaebc5f..4f99188124 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-enum-flat-field-metadata.util.ts @@ -17,7 +17,7 @@ import { import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-type-validator.type'; import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type'; -import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { isSnakeCaseString } from 'src/utils/is-snake-case-string'; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util.ts index 005f3cef0b..37958b0d52 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-flat-field-metadata-name.util.ts @@ -1,9 +1,11 @@ import { msg, t } from '@lingui/core/macro'; -import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata'; +import { + IDENTIFIER_MAX_CHAR_LENGTH, + RESERVED_METADATA_NAME_KEYWORDS, +} from 'twenty-shared/metadata'; import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type'; -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'; import { isCallerTwentyStandardApp } from 'src/engine/metadata-modules/utils/is-caller-twenty-standard-app.util'; import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type'; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts index e04e669af8..ddf0e20ded 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/are-flat-object-metadata-names-synced-with-labels.util.ts @@ -1,4 +1,4 @@ -import { computeMetadataNameFromLabel } from 'twenty-shared/metadata'; +import { computeMetadataNamesFromLabelsOrThrow } from 'twenty-shared/metadata'; import { isCallerTwentyStandardApp } from 'src/engine/metadata-modules/utils/is-caller-twenty-standard-app.util'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; @@ -14,15 +14,12 @@ export const areFlatObjectMetadataNamesSyncedWithLabels = ({ 'namePlural' | 'nameSingular' | 'labelPlural' | 'labelSingular' >; }) => { - const [computedSingularName, computedPluralName] = [ - flatObjectMetadata.labelSingular, - flatObjectMetadata.labelPlural, - ].map((label) => - computeMetadataNameFromLabel({ - label, + const { nameSingular: computedSingularName, namePlural: computedPluralName } = + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: flatObjectMetadata.labelSingular, + labelPlural: flatObjectMetadata.labelPlural, applyCustomSuffix: !isCallerTwentyStandardApp(buildOptions), - }), - ); + }); return ( flatObjectMetadata.nameSingular === computedSingularName && diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util.ts index dc6f168284..c3e3a41056 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-label.util.ts @@ -3,7 +3,7 @@ import { msg } from '@lingui/core/macro'; import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type'; import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type'; import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; @@ -37,18 +37,5 @@ export const validateFlatObjectMetadataLabel = ({ } } - // Check if labels are identical - const labelsAreIdentical = - labelSingular.trim().toLowerCase() === labelPlural.trim().toLowerCase(); - - if (labelsAreIdentical) { - errors.push({ - code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT, - message: `The singular and plural labels cannot be the same for an object`, - userFriendlyMessage: msg`The singular and plural labels cannot be the same for an object`, - value: labelSingular, - }); - } - return errors; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util.ts index 0f30ecb26f..f9d6109d6d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-name.util.ts @@ -1,10 +1,12 @@ import { msg } from '@lingui/core/macro'; -import { RESERVED_METADATA_NAME_KEYWORDS } from 'twenty-shared/metadata'; +import { + IDENTIFIER_MAX_CHAR_LENGTH, + RESERVED_METADATA_NAME_KEYWORDS, +} from 'twenty-shared/metadata'; import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type'; import { type ObjectMetadataMinimalInformation } from 'src/engine/metadata-modules/flat-object-metadata/types/object-metadata-minimal-information.type'; import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception'; -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'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants.ts b/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants.ts deleted file mode 100644 index ce2dabd729..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const IDENTIFIER_MAX_CHAR_LENGTH = 63; diff --git a/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts b/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts index 2776fb920e..fe22aacb4d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts +++ b/packages/twenty-server/src/engine/metadata-modules/utils/validate-database-identifier-length.utils.ts @@ -1,4 +1,4 @@ -import { IDENTIFIER_MAX_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-max-char-length.constants'; +import { IDENTIFIER_MAX_CHAR_LENGTH } from 'twenty-shared/metadata'; import { IDENTIFIER_MIN_CHAR_LENGTH } from 'src/engine/metadata-modules/utils/constants/identifier-min-char-length.constants'; export const exceedsDatabaseIdentifierMaximumLength = (string: string) => diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap index 042ab02579..da90bf50f8 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/__snapshots__/failing-create-one-object-metadata-v2.integration-spec.ts.snap @@ -2950,1470 +2950,6 @@ exports[`Object metadata creation should fail v2 when labelSingular is empty 1`] } `; -exports[`Object metadata creation should fail v2 when labels are identical 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "fieldMetadata": [ - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "name", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "id", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "createdAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "createdBy", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "deletedAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "position", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "searchVector", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "updatedAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "updatedBy", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "timelineActivities", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "favorites", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "attachments", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "noteTargets", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "taskTargets", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "listinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - ], - "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, - ], - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_OBJECT_INPUT", - "message": "The singular and plural labels cannot be the same for an object", - "userFriendlyMessage": "The singular and plural labels cannot be the same for an object", - "value": "FooBar", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "listingas", - "nameSingular": "listinga", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "status": "fail", - "type": "create", - }, - ], - "view": [ - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Object metadata not found", - "userFriendlyMessage": "Object metadata not found", - }, - ], - "flatEntityMinimalInformation": { - "universalIdentifier": Any, - }, - "metadataName": "view", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Object metadata not found", - "userFriendlyMessage": "Object metadata not found", - }, - ], - "flatEntityMinimalInformation": { - "universalIdentifier": Any, - }, - "metadataName": "view", - "status": "fail", - "type": "create", - }, - ], - "viewField": [ - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - ], - }, - "message": "Validation failed for 19 fieldMetadatas, 1 objectMetadata, 2 views, 14 viewFields, 1 index", - "summary": { - "fieldMetadata": 19, - "index": 1, - "objectMetadata": 1, - "totalErrors": 37, - "view": 2, - "viewField": 14, - }, - "userFriendlyMessage": "Metadata validation failed", - }, - "message": "Multiple validation errors occurred while creating object", - "name": "GraphQLError", -} -`; - -exports[`Object metadata creation should fail v2 when labels with whitespaces result to be identical 1`] = ` -{ - "extensions": { - "code": "METADATA_VALIDATION_FAILED", - "errors": { - "fieldMetadata": [ - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "name", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "id", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "createdAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "createdBy", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "deletedAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "position", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "searchVector", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "updatedAt", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "updatedBy", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "timelineActivities", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "favorites", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "attachments", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "noteTargets", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Object metadata not found", - "userFriendlyMessage": "Field to create related object not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "taskTargets", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "listinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "OBJECT_METADATA_NOT_FOUND", - "message": "Relation target object metadata not found", - "userFriendlyMessage": "Object targeted by the relation not found", - }, - ], - "flatEntityMinimalInformation": { - "name": "targetListinga", - "objectMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - }, - "metadataName": "fieldMetadata", - "status": "fail", - "type": "create", - }, - ], - "index": [ - { - "errors": [ - { - "code": "INDEX_OBJECT_NOT_FOUND", - "message": "Could not find index related object metadata", - "userFriendlyMessage": "Index related object not found", - }, - { - "code": "INDEX_FIELD_NOT_FOUND", - "message": "Could not find index field related field metadata", - "userFriendlyMessage": "Field referenced in index does not exist", - }, - ], - "flatEntityMinimalInformation": { - "name": "IDX_ffebb98ec981552dbecc71223c8", - "universalIdentifier": Any, - }, - "metadataName": "index", - "status": "fail", - "type": "create", - }, - ], - "objectMetadata": [ - { - "errors": [ - { - "code": "INVALID_OBJECT_INPUT", - "message": "The singular and plural labels cannot be the same for an object", - "userFriendlyMessage": "The singular and plural labels cannot be the same for an object", - "value": "FooBar", - }, - ], - "flatEntityMinimalInformation": { - "namePlural": "listingas", - "nameSingular": "listinga", - "universalIdentifier": Any, - }, - "metadataName": "objectMetadata", - "status": "fail", - "type": "create", - }, - ], - "view": [ - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Object metadata not found", - "userFriendlyMessage": "Object metadata not found", - }, - ], - "flatEntityMinimalInformation": { - "universalIdentifier": Any, - }, - "metadataName": "view", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Object metadata not found", - "userFriendlyMessage": "Object metadata not found", - }, - ], - "flatEntityMinimalInformation": { - "universalIdentifier": Any, - }, - "metadataName": "view", - "status": "fail", - "type": "create", - }, - ], - "viewField": [ - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - { - "errors": [ - { - "code": "INVALID_VIEW_DATA", - "message": "Field metadata not found", - "userFriendlyMessage": "Field metadata not found", - }, - { - "code": "INVALID_VIEW_DATA", - "message": "View not found", - "userFriendlyMessage": "View not found", - }, - ], - "flatEntityMinimalInformation": { - "fieldMetadataUniversalIdentifier": Any, - "universalIdentifier": Any, - "viewUniversalIdentifier": Any, - }, - "metadataName": "viewField", - "status": "fail", - "type": "create", - }, - ], - }, - "message": "Validation failed for 19 fieldMetadatas, 1 objectMetadata, 2 views, 14 viewFields, 1 index", - "summary": { - "fieldMetadata": 19, - "index": 1, - "objectMetadata": 1, - "totalErrors": 37, - "view": 2, - "viewField": 14, - }, - "userFriendlyMessage": "Metadata validation failed", - }, - "message": "Multiple validation errors occurred while creating object", - "name": "GraphQLError", -} -`; - exports[`Object metadata creation should fail v2 when name exceeds maximum length 1`] = ` { "extensions": { diff --git a/packages/twenty-server/test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases.ts b/packages/twenty-server/test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases.ts index 60ccc1723c..b7cb7c0430 100644 --- a/packages/twenty-server/test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases.ts +++ b/packages/twenty-server/test/integration/metadata/suites/object-metadata/common/object-metadata-label-failing-tests-cases.ts @@ -29,18 +29,4 @@ export const OBJECT_METADATA_LABEL_FAILING_TEST_CASES: EachTestingContext< 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', - }, - }, ]; diff --git a/packages/twenty-shared/src/metadata/__tests__/computeMetadataNamesFromLabelsOrThrow.test.ts b/packages/twenty-shared/src/metadata/__tests__/computeMetadataNamesFromLabelsOrThrow.test.ts new file mode 100644 index 0000000000..3bc8139030 --- /dev/null +++ b/packages/twenty-shared/src/metadata/__tests__/computeMetadataNamesFromLabelsOrThrow.test.ts @@ -0,0 +1,96 @@ +import { computeMetadataNamesFromLabelsOrThrow } from '@/metadata/utils/compute-metadata-names-from-labels-or-throw.util'; + +describe('computeMetadataNamesFromLabelsOrThrow', () => { + it('should compute different names from different labels', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Company', + labelPlural: 'Companies', + }), + ).toEqual({ + nameSingular: 'company', + namePlural: 'companies', + }); + }); + + it('should append "s" to plural name when labels produce identical names', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Sheep', + labelPlural: 'Sheep', + }), + ).toEqual({ + nameSingular: 'sheep', + namePlural: 'sheeps', + }); + }); + + it('should handle German words with identical singular and plural', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Unternehmen', + labelPlural: 'Unternehmen', + }), + ).toEqual({ + nameSingular: 'unternehmen', + namePlural: 'unternehmens', + }); + }); + + it('should not append "s" when labels produce different names', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Person', + labelPlural: 'People', + }), + ).toEqual({ + nameSingular: 'person', + namePlural: 'people', + }); + }); + + it('should handle empty labels gracefully', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: '', + labelPlural: '', + }), + ).toEqual({ + nameSingular: '', + namePlural: '', + }); + }); + + it('should apply custom suffix for reserved words by default', () => { + const result = computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Job', + labelPlural: 'Jobs', + }); + + expect(result.nameSingular).toBe('jobCustom'); + expect(result.namePlural).toBe('jobsCustom'); + }); + + it('should skip custom suffix when applyCustomSuffix is false', () => { + const result = computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Job', + labelPlural: 'Jobs', + applyCustomSuffix: false, + }); + + expect(result.nameSingular).toBe('job'); + expect(result.namePlural).toBe('jobs'); + }); + + it('should handle case-insensitive collision after camelCase conversion', () => { + expect( + computeMetadataNamesFromLabelsOrThrow({ + labelSingular: 'Aircraft', + labelPlural: 'Aircraft', + }), + ).toEqual({ + nameSingular: 'aircraft', + namePlural: 'aircrafts', + }); + }); +}); diff --git a/packages/twenty-shared/src/metadata/constants/identifier-max-char-length.constant.ts b/packages/twenty-shared/src/metadata/constants/identifier-max-char-length.constant.ts new file mode 100644 index 0000000000..2b9c601f59 --- /dev/null +++ b/packages/twenty-shared/src/metadata/constants/identifier-max-char-length.constant.ts @@ -0,0 +1,2 @@ +// PostgreSQL max identifier length (NAMEDATALEN - 1) +export const IDENTIFIER_MAX_CHAR_LENGTH = 63; diff --git a/packages/twenty-shared/src/metadata/index.ts b/packages/twenty-shared/src/metadata/index.ts index 933edeedbf..d6b5c64224 100644 --- a/packages/twenty-shared/src/metadata/index.ts +++ b/packages/twenty-shared/src/metadata/index.ts @@ -14,6 +14,7 @@ export { } from './check-if-field-is-label-identifier.util'; export { ALL_METADATA_NAME } from './constants/all-metadata-name.constant'; export { DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS } from './constants/default-relations-object-standard-ids.constant'; +export { IDENTIFIER_MAX_CHAR_LENGTH } from './constants/identifier-max-char-length.constant'; export { RESERVED_METADATA_NAME_KEYWORDS } from './constants/reserved-metadata-name-keywords.constant'; export { STANDARD_OBJECTS } from './constants/standard-object.constant'; export type { AllMetadataName } from './types/all-metadata-name.type'; @@ -25,3 +26,4 @@ export type { export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError'; export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util'; export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util'; +export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util'; diff --git a/packages/twenty-shared/src/metadata/utils/compute-metadata-names-from-labels-or-throw.util.ts b/packages/twenty-shared/src/metadata/utils/compute-metadata-names-from-labels-or-throw.util.ts new file mode 100644 index 0000000000..b1a0fb19a4 --- /dev/null +++ b/packages/twenty-shared/src/metadata/utils/compute-metadata-names-from-labels-or-throw.util.ts @@ -0,0 +1,28 @@ +import { IDENTIFIER_MAX_CHAR_LENGTH } from '@/metadata/constants/identifier-max-char-length.constant'; +import { computeMetadataNameFromLabel } from '@/metadata/utils/compute-metadata-name-from-label.util'; + +export const computeMetadataNamesFromLabelsOrThrow = ({ + labelSingular, + labelPlural, + applyCustomSuffix = true, +}: { + labelSingular: string; + labelPlural: string; + applyCustomSuffix?: boolean; +}): { nameSingular: string; namePlural: string } => { + const nameSingular = computeMetadataNameFromLabel({ + label: labelSingular, + applyCustomSuffix, + }); + + let namePlural = computeMetadataNameFromLabel({ + label: labelPlural, + applyCustomSuffix, + }); + + if (namePlural !== '' && namePlural === nameSingular) { + namePlural = (namePlural + 's').slice(0, IDENTIFIER_MAX_CHAR_LENGTH); + } + + return { nameSingular, namePlural }; +};