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:
+1
-1
@@ -210,7 +210,7 @@ export abstract class GraphqlQueryBaseResolverService<
|
||||
|
||||
return resultWithGetters;
|
||||
} catch (error) {
|
||||
workspaceQueryRunnerGraphqlApiExceptionHandler(error, options);
|
||||
workspaceQueryRunnerGraphqlApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
-22
@@ -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
-20
@@ -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:
|
||||
|
||||
+25
@@ -3,6 +3,7 @@ import {
|
||||
type HttpException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Scope,
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
@@ -13,7 +14,12 @@ import { QueryFailedError } from 'typeorm';
|
||||
import { type ExceptionHandlerUser } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-user.interface';
|
||||
import { type ExceptionHandlerWorkspace } from 'src/engine/core-modules/exception-handler/interfaces/exception-handler-workspace.interface';
|
||||
|
||||
import { PostgresException } from 'src/engine/api/graphql/workspace-query-runner/utils/postgres-exception';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import {
|
||||
TwentyORMException,
|
||||
TwentyORMExceptionCode,
|
||||
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
|
||||
import { handleException } from 'src/engine/utils/global-exception-handler.util';
|
||||
|
||||
interface RequestAndParams {
|
||||
@@ -84,6 +90,25 @@ export class HttpExceptionHandlerService {
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
if (
|
||||
exception instanceof TwentyORMException &&
|
||||
[
|
||||
TwentyORMExceptionCode.INVALID_INPUT,
|
||||
TwentyORMExceptionCode.DUPLICATE_ENTRY_DETECTED,
|
||||
TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR,
|
||||
TwentyORMExceptionCode.CONNECT_NOT_ALLOWED,
|
||||
TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND,
|
||||
].includes(exception.code)
|
||||
) {
|
||||
exception = new BadRequestException(exception.message);
|
||||
statusCode = 400;
|
||||
}
|
||||
|
||||
if (exception instanceof PostgresException) {
|
||||
exception = new InternalServerErrorException(exception.message);
|
||||
statusCode = 500;
|
||||
}
|
||||
|
||||
handleException({
|
||||
exception,
|
||||
exceptionHandlerService: this.exceptionHandlerService,
|
||||
|
||||
+36
@@ -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;
|
||||
};
|
||||
+7
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-1
@@ -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',
|
||||
}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -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}`,
|
||||
};
|
||||
};
|
||||
|
||||
+6
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user