diff --git a/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts b/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts new file mode 100644 index 000000000000..1cb86780a30d --- /dev/null +++ b/packages/repository/src/__tests__/unit/errors/database-driver-error.test.ts @@ -0,0 +1,55 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {DatabaseDriverError, isDatabaseDriverError} from '../../..'; + +describe('DatabaseDriverError', () => { + it('inherits from Error correctly', () => { + const err = givenAnErrorInstance(); + expect(err).to.be.instanceof(DatabaseDriverError); + expect(err).to.be.instanceof(Error); + expect(err.stack) + .to.be.String() + // NOTE(bajtos) We cannot assert using __filename because stack traces + // are typically converted from JS paths to TS paths using source maps. + .and.match(/database-driver-error\.test\.(ts|js)/); + }); + + it('sets code to "DB_FOREIGN_KEY_VIOLATION"', () => { + const err = givenAnErrorInstance(); + expect(err.code).to.equal('DB_FOREIGN_KEY_VIOLATION'); + }); + + it('sets statusCode to 422', () => { + const err = givenAnErrorInstance(); + expect(err.statusCode).to.equal(422); + }); + + it('sets nativeCode to "1216"', () => { + const err = givenAnErrorInstance(); + expect(err.nativeCode).to.equal('1216'); // mysql's native code for foreign key violation + }); +}); + +describe('isDatabaseDriverError', () => { + it('returns true for an instance of DatabaseDriverError', () => { + const error = givenAnErrorInstance(); + expect(isDatabaseDriverError(error)).to.be.true(); + }); + + it('returns false for an instance of Error', () => { + const error = new Error('A generic error'); + expect(isDatabaseDriverError(error)).to.be.false(); + }); +}); + +function givenAnErrorInstance() { + return new DatabaseDriverError('User', '', { + code: 'DB_FOREIGN_KEY_VIOLATION', + statusCode: 422, + nativeCode: '1216', // mysql's native code for foreign key violation + }); +} diff --git a/packages/repository/src/errors/database-driver.error.ts b/packages/repository/src/errors/database-driver.error.ts new file mode 100644 index 000000000000..6ff4ffaaca7b --- /dev/null +++ b/packages/repository/src/errors/database-driver.error.ts @@ -0,0 +1,43 @@ +// Copyright IBM Corp. and LoopBack contributors 2018,2019. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Entity} from '../model'; + +export class DatabaseDriverError extends Error { + code: string; + statusCode: number; + entityName: string; + nativeCode: string | number; + + constructor( + entityOrName: typeof Entity | string, + message: string, + options: { + code: string; + statusCode: number; + nativeCode: string | number; + }, + ) { + const entityName = + typeof entityOrName === 'string' + ? entityOrName + : entityOrName.modelName || entityOrName.name; + + super(message); + + this.name = 'DatabaseDriverError'; + this.entityName = entityName; + this.code = options.code; + this.statusCode = options.statusCode; + this.nativeCode = options.nativeCode; + + Error.captureStackTrace(this, this.constructor); + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isDatabaseDriverError(e: any): e is DatabaseDriverError { + return e instanceof DatabaseDriverError; +} diff --git a/packages/repository/src/errors/index.ts b/packages/repository/src/errors/index.ts index ac99f724d828..196a5a0545c8 100644 --- a/packages/repository/src/errors/index.ts +++ b/packages/repository/src/errors/index.ts @@ -7,3 +7,4 @@ export * from './entity-not-found.error'; export * from './invalid-polymorphism.error'; export * from './invalid-relation.error'; export * from './invalid-body.error'; +export * from './database-driver.error'; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 025ea10393e0..19be928f09eb 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -78,6 +78,106 @@ function isModelClass( ); } +import {DatabaseDriverError} from '../errors'; + +function handleDatabaseDriverError( + this: DefaultCrudRepository, + err: unknown, +): never { + const error = err as AnyObject; + if (err === null || err === undefined) { + throw new Error('An unknown database execution error occurred.'); + } + + // Handling existing already mapped errors + if (error.statusCode && error.statusCode >= 400 && error.statusCode < 500) { + throw error; + } + + const parsedCode = Number(error.code); + const rawCode = !isNaN(parsedCode) ? error.code : error.errno; // error.code for posgres while errno for mysql/mongodb + + const codeStr = String(rawCode); + + // Initialize with default values + let statusCode = 500; + let errorCode = 'DATABASE_ERROR'; + let message = error.message || 'An unexpected database error occurred.'; + + // Evaluate database signatures and re-map properties dynamically + switch (codeStr) { + // 1. Unique Key / Duplicate Entries + case '23505': // Postgres + case '1062': // MySQL + case '11000': // MongoDB + case '11001': + statusCode = 409; + errorCode = 'DB_UNIQUE_CONSTRAINT_VIOLATION'; + message = + 'The operation conflicts with an existing record unique constraint.'; + break; + // 2. Foreign Key Constraints (Missing Parents / Existing Children) + case '23503': // Postgres + case '1216': // MySQL + case '1217': + case '1451': + case '1452': + statusCode = 422; + errorCode = 'DB_FOREIGN_KEY_VIOLATION'; + message = + 'Relational integrity validation failed. Referenced parent record not found.'; + break; + // 3. Null / Required Fields Omissions + case '23502': // Postgres + case '1048': // MySQL + case '1364': + case '121': // MongoDB Document Validation Failed + statusCode = 400; + errorCode = 'DB_NOT_NULL_VIOLATION'; + message = 'Required database schema properties are missing or null.'; + break; + // 4. Bad Casts / Truncation / Data Type Mismatch + case '22P02': // Postgres Invalid Text Representation (e.g. Bad UUID format) + case '22001': // Postgres String Data Right Truncation + case '1265': // MySQL Data Truncated + case '1366': + statusCode = 400; + errorCode = 'DB_DATA_TYPE_MISMATCH'; + message = + 'The query properties contain unexpected formatting types or overflows.'; + break; + // 5. Generated Column Violations + case '3105': // MySQL Server Generated column value ignored/disallowed + case '1906': // MariaDB Generated column value ignored/disallowed + statusCode = 400; + errorCode = 'DB_GENERATED_COLUMN_VIOLATION'; + message = + 'Cannot manually assign or update values on a database-generated computed column.'; + break; + // 6. Missing Table / Schema Definition Errors + case '1146': // MySQL errno for missing table + case '42P01': // Postgres error code for undefined_table + statusCode = 400; + errorCode = 'DB_SCHEMA_MISSING_TABLE'; + message = + error.message || + 'The requested database table or relation does not exist.'; + break; + } + + // If we matched a standard driver rule, throw our clean uniform class + if (statusCode !== 500) { + throw new DatabaseDriverError(this.entityClass, message, { + code: errorCode, + statusCode: statusCode, + nativeCode: rawCode, + }); + } + + // Otherwise, bubble up the original error safely to protect core connection strings/etc. + throw err; +} + /** * This is a bridge to the legacy DAO class. The function mixes DAO methods * into a model class and attach it to a given data source @@ -488,7 +588,9 @@ export class DefaultCrudRepository< async create(entity: DataObject, options?: Options): Promise { // perform persist hook const data = await this.entityToData(entity, options); - const model = await ensurePromise(this.modelClass.create(data, options)); + const model = await ensurePromise( + this.modelClass.create(data, options), + ).catch(handleDatabaseDriverError); return this.toEntity(model); } @@ -499,7 +601,7 @@ export class DefaultCrudRepository< ); const models = await ensurePromise( this.modelClass.createAll(data, options), - ); + ).catch(handleDatabaseDriverError); return this.toEntities(models); } @@ -520,7 +622,7 @@ export class DefaultCrudRepository< const include = filter?.include; const models = await ensurePromise( this.modelClass.find(this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); const entities = this.toEntities(models); return this.includeRelatedModels(entities, include, options); } @@ -531,7 +633,7 @@ export class DefaultCrudRepository< ): Promise<(T & Relations) | null> { const model = await ensurePromise( this.modelClass.findOne(this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); if (!model) return null; const entity = this.toEntity(model); const include = filter?.include; @@ -551,7 +653,7 @@ export class DefaultCrudRepository< const include = filter?.include; const model = await ensurePromise( this.modelClass.findById(id, this.normalizeFilter(filter), options), - ); + ).catch(handleDatabaseDriverError); if (!model) { throw new EntityNotFoundError(this.entityClass, id); } @@ -583,7 +685,7 @@ export class DefaultCrudRepository< const persistedData = await this.entityToData(data, options); const result = await ensurePromise( this.modelClass.updateAll(where, persistedData, options), - ); + ).catch(handleDatabaseDriverError); return {count: result.count}; } @@ -614,7 +716,9 @@ export class DefaultCrudRepository< ): Promise { try { const payload = await this.entityToData(data, options); - await ensurePromise(this.modelClass.replaceById(id, payload, options)); + await ensurePromise( + this.modelClass.replaceById(id, payload, options), + ).catch(handleDatabaseDriverError); } catch (err) { if (err.statusCode === 404) { throw new EntityNotFoundError(this.entityClass, id); @@ -626,24 +730,30 @@ export class DefaultCrudRepository< async deleteAll(where?: Where, options?: Options): Promise { const result = await ensurePromise( this.modelClass.deleteAll(where, options), - ); + ).catch(handleDatabaseDriverError); return {count: result.count}; } async deleteById(id: ID, options?: Options): Promise { - const result = await ensurePromise(this.modelClass.deleteById(id, options)); + const result = await ensurePromise( + this.modelClass.deleteById(id, options), + ).catch(handleDatabaseDriverError); if (result.count === 0) { throw new EntityNotFoundError(this.entityClass, id); } } async count(where?: Where, options?: Options): Promise { - const result = await ensurePromise(this.modelClass.count(where, options)); + const result = await ensurePromise( + this.modelClass.count(where, options), + ).catch(handleDatabaseDriverError); return {count: result}; } exists(id: ID, options?: Options): Promise { - return ensurePromise(this.modelClass.exists(id, options)); + return ensurePromise(this.modelClass.exists(id, options)).catch( + handleDatabaseDriverError, + ); } /**