Move duplicate key error handling in ORM (#13893)

Error available in REST API
<img width="1360" height="653" alt="Screenshot 2025-08-13 at 11 48 19"
src="https://github.com/user-attachments/assets/a055859d-ce54-4ff6-9aba-fac48e03a98d"
/>

`curl http://localhost:3000/rest/people \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "emails": {
    "primaryEmail": "test@test.com"
  }
}'`

closes https://github.com/twentyhq/twenty/issues/13567
This commit is contained in:
Etienne
2025-08-14 18:38:04 +02:00
committed by GitHub
parent 5c61daa201
commit f6d0af5fb8
13 changed files with 156 additions and 50 deletions
@@ -1,9 +1,12 @@
import { isDefined } from 'twenty-shared/utils';
import { type QueryFailedError } from 'typeorm';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
interface PostgreSQLError extends QueryFailedError {
detail?: string;
@@ -11,7 +14,7 @@ interface PostgreSQLError extends QueryFailedError {
export const handleDuplicateKeyError = (
error: PostgreSQLError,
context: WorkspaceQueryRunnerOptions,
objectMetadata: ObjectMetadataItemWithFieldMaps,
) => {
const indexNameMatch = error.message.match(/"([^"]+)"/);
@@ -20,28 +23,28 @@ export const handleDuplicateKeyError = (
if (indexNameMatch) {
const indexName = indexNameMatch[1];
const deletedAtFieldMetadataId =
context.objectMetadataItemWithFieldMaps.fieldIdByName['deletedAt'];
const deletedAtFieldMetadataId = objectMetadata.fieldIdByName['deletedAt'];
const affectedColumns =
context.objectMetadataItemWithFieldMaps.indexMetadatas
.find((index) => index.name === indexName)
?.indexFieldMetadatas?.filter(
(field) => field.fieldMetadataId !== deletedAtFieldMetadataId,
)
.map((indexField) => {
const fieldMetadata =
context.objectMetadataItemWithFieldMaps.fieldsById[
indexField.fieldMetadataId
];
const affectedColumns = objectMetadata.indexMetadatas
.find((index) => index.name === indexName)
?.indexFieldMetadatas?.filter(
(field) => field.fieldMetadataId !== deletedAtFieldMetadataId,
)
.map((indexField) => {
const fieldMetadata =
objectMetadata.fieldsById[indexField.fieldMetadataId];
return fieldMetadata?.label;
});
return fieldMetadata?.label;
});
if (!isDefined(affectedColumns)) {
throw new UserInputError(`A duplicate entry was detected`, {
userFriendlyMessage: `This record already exists. Please check your data and try again.`,
});
throw new TwentyORMException(
`A duplicate entry was detected`,
TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED,
{
userFriendlyMessage: `This record already exists. Please check your data and try again.`,
},
);
}
const columnNames = affectedColumns.join(', ');
@@ -55,8 +58,9 @@ export const handleDuplicateKeyError = (
);
}
throw new UserInputError(
throw new TwentyORMException(
`A duplicate entry was detected. The combination of ${columnNames} must be unique.`,
TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED,
{
userFriendlyMessage: `This combination of ${columnNames.toLowerCase()} already exists. Please use different values.`,
},
@@ -1,12 +1,7 @@
import { QueryFailedError } from 'typeorm';
import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
import { type QueryFailedError } from 'typeorm';
import { GraphqlQueryRunnerException } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
import { graphqlQueryRunnerExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/graphql-query-runner-exception-handler.util';
import { handleDuplicateKeyError } from 'src/engine/api/graphql/workspace-query-runner/utils/handle-duplicate-key-error.util';
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
import { workspaceExceptionHandler } from 'src/engine/api/graphql/workspace-query-runner/utils/workspace-exception-handler.util';
import { WorkspaceQueryRunnerException } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.exception';
import { ApiKeyException } from 'src/engine/core-modules/api-key/api-key.exception';
@@ -26,22 +21,8 @@ interface QueryFailedErrorWithCode extends QueryFailedError {
export const workspaceQueryRunnerGraphqlApiExceptionHandler = (
error: QueryFailedErrorWithCode,
context: WorkspaceQueryRunnerOptions,
) => {
switch (true) {
case error instanceof QueryFailedError: {
if (
error.message.includes('duplicate key value violates unique constraint')
) {
return handleDuplicateKeyError(error, context);
}
const errorCode = (error as QueryFailedErrorWithCode).code;
if (POSTGRESQL_ERROR_CODES.includes(errorCode)) {
throw new PostgresException(error.message, errorCode);
}
throw error;
}
case error instanceof RecordTransformerException:
return recordTransformerGraphqlApiExceptionHandler(error);
case error instanceof PermissionsException: