fix: allow identical singular and plural labels for objects (#18678)

## 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)
This commit is contained in:
Félix Malfait
2026-03-16 18:07:34 +01:00
committed by GitHub
parent 13ff7af297
commit c4e55d08ff
23 changed files with 204 additions and 1572 deletions
@@ -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';
@@ -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';
@@ -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 &&
@@ -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;
};
@@ -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';
@@ -1 +0,0 @@
export const IDENTIFIER_MAX_CHAR_LENGTH = 63;
@@ -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) =>