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
@@ -0,0 +1,36 @@
import { isDefined } from 'twenty-shared/utils';
import {
type EntityTarget,
InstanceChecker,
type ObjectLiteral,
type SaveOptions,
} from 'typeorm';
import { type DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/deep-partial-entity-with-nested-relation-fields.type';
export const getEntityTarget = <
Entity extends ObjectLiteral,
T extends DeepPartialWithNestedRelationFields<Entity>,
>(
targetOrEntity: EntityTarget<Entity> | Entity | Entity[],
entityOrMaybeOptions:
| T
| T[]
| SaveOptions
| (SaveOptions & { reload: false }),
) => {
const isEntityTarget =
(typeof targetOrEntity === 'function' ||
InstanceChecker.isEntitySchema(targetOrEntity) ||
typeof targetOrEntity === 'string') &&
isDefined(targetOrEntity);
const entityTarget = isEntityTarget ? targetOrEntity : null;
if (entityTarget) return entityTarget;
const entityData = isEntityTarget ? entityOrMaybeOptions : targetOrEntity;
const isEntityArray = Array.isArray(entityData);
return isEntityArray ? entityData[0]?.constructor : entityData.constructor;
};
@@ -44,6 +44,7 @@ import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-module
import { type WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { type DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/deep-partial-entity-with-nested-relation-fields.type';
import { type QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type';
import { getEntityTarget } from 'src/engine/twenty-orm/entity-manager/utils/get-entity-target';
import { computeTwentyORMException } from 'src/engine/twenty-orm/error-handling/compute-twenty-orm-exception';
import { RelationNestedQueries } from 'src/engine/twenty-orm/relation-nested-queries/relation-nested-queries';
import {
@@ -1230,7 +1231,12 @@ export class WorkspaceEntityManager extends EntityManager {
return isEntityArray ? formattedResult : formattedResult[0];
} catch (error) {
throw computeTwentyORMException(error);
const objectMetadataItem = getObjectMetadataFromEntityTarget(
getEntityTarget(targetOrEntity, entityOrMaybeOptions),
this.internalContext,
);
throw computeTwentyORMException(error, objectMetadataItem);
}
}
@@ -1,12 +1,24 @@
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { QueryFailedError } from 'typeorm';
import { POSTGRESQL_ERROR_CODES } from 'src/engine/api/graphql/workspace-query-runner/constants/postgres-error-codes.constants';
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 { 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';
export const computeTwentyORMException = (error: Error) => {
interface QueryFailedErrorWithCode extends QueryFailedError {
code?: string;
}
export const computeTwentyORMException = (
error: Error,
objectMetadata?: ObjectMetadataItemWithFieldMaps,
) => {
if (error instanceof QueryFailedError) {
if (error.message.includes('Query read timeout')) {
return new TwentyORMException(
@@ -17,6 +29,29 @@ export const computeTwentyORMException = (error: Error) => {
},
);
}
if (
error.message.includes(
'duplicate key value violates unique constraint',
) &&
isDefined(objectMetadata)
) {
return handleDuplicateKeyError(error, objectMetadata);
}
if (error.message.includes('invalid input value for')) {
return new TwentyORMException(
error.message,
TwentyORMExceptionCode.INVALID_INPUT,
);
}
const errorCode = (error as QueryFailedErrorWithCode).code;
if (isDefined(errorCode) && POSTGRESQL_ERROR_CODES.includes(errorCode)) {
throw new PostgresException(error.message, errorCode);
}
throw error;
}
return error;
@@ -18,4 +18,6 @@ export enum TwentyORMExceptionCode {
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
ENUM_TYPE_NAME_NOT_FOUND = 'ENUM_TYPE_NAME_NOT_FOUND',
QUERY_READ_TIMEOUT = 'QUERY_READ_TIMEOUT',
DUPLICATE_ENTRY_DETECTED = 'DUPLICATE_ENTRY_DETECTED',
INVALID_INPUT = 'INVALID_INPUT',
}
@@ -15,7 +15,7 @@ describe('formatConnectRecordNotFoundErrorMessage', () => {
errorMessage:
'Expected 1 record to connect to connectFieldName, but found 0 for field1 = value1 and field2 = value2',
userFriendlyMessage:
'Expected 1 record to connect to connectFieldName, but found 0 for field1 = value1 and field2 = value2',
"Can't connect to connectFieldName. No unique record found with condition: field1 = value1 and field2 = value2",
});
});
});
@@ -13,6 +13,6 @@ export const formatConnectRecordNotFoundErrorMessage = (
return {
errorMessage: `Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal} for ${formattedConnectCondition}`,
userFriendlyMessage: t`Expected 1 record to connect to ${connectFieldName}, but found ${recordToConnectTotal} for ${formattedConnectCondition}`,
userFriendlyMessage: t`Can't connect to ${connectFieldName}. No unique record found with condition: ${formattedConnectCondition}`,
};
};
@@ -200,7 +200,12 @@ export class WorkspaceInsertQueryBuilder<
identifiers: result.identifiers,
};
} catch (error) {
throw computeTwentyORMException(error);
const objectMetadata = getObjectMetadataFromEntityTarget(
this.getMainAliasTarget(),
this.internalContext,
);
throw computeTwentyORMException(error, objectMetadata);
}
}
@@ -176,7 +176,12 @@ export class WorkspaceUpdateQueryBuilder<
affected: result.affected,
};
} catch (error) {
throw computeTwentyORMException(error);
const objectMetadata = getObjectMetadataFromEntityTarget(
this.getMainAliasTarget(),
this.internalContext,
);
throw computeTwentyORMException(error, objectMetadata);
}
}
@@ -305,7 +310,12 @@ export class WorkspaceUpdateQueryBuilder<
affected: results.length,
};
} catch (error) {
throw computeTwentyORMException(error);
const objectMetadata = getObjectMetadataFromEntityTarget(
this.getMainAliasTarget(),
this.internalContext,
);
throw computeTwentyORMException(error, objectMetadata);
}
}
@@ -8,6 +8,8 @@ export const twentyORMGraphqlApiExceptionHandler = (
error: TwentyORMException,
) => {
switch (error.code) {
case TwentyORMExceptionCode.INVALID_INPUT:
case TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED:
case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND:
case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED:
case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR: