Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
});
}
43 changes: 43 additions & 0 deletions packages/repository/src/errors/database-driver.error.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions packages/repository/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
132 changes: 121 additions & 11 deletions packages/repository/src/repositories/legacy-juggler-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,106 @@ function isModelClass(
);
}

import {DatabaseDriverError} from '../errors';

function handleDatabaseDriverError(
this: DefaultCrudRepository<Entity, unknown, AnyObject>,
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
Expand Down Expand Up @@ -488,7 +588,9 @@ export class DefaultCrudRepository<
async create(entity: DataObject<T>, options?: Options): Promise<T> {
// 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);
}

Expand All @@ -499,7 +601,7 @@ export class DefaultCrudRepository<
);
const models = await ensurePromise(
this.modelClass.createAll(data, options),
);
).catch(handleDatabaseDriverError);
return this.toEntities(models);
}

Expand All @@ -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);
}
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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};
}

Expand Down Expand Up @@ -614,7 +716,9 @@ export class DefaultCrudRepository<
): Promise<void> {
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);
Expand All @@ -626,24 +730,30 @@ export class DefaultCrudRepository<
async deleteAll(where?: Where<T>, options?: Options): Promise<Count> {
const result = await ensurePromise(
this.modelClass.deleteAll(where, options),
);
).catch(handleDatabaseDriverError);
return {count: result.count};
}

async deleteById(id: ID, options?: Options): Promise<void> {
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<T>, options?: Options): Promise<Count> {
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<boolean> {
return ensurePromise(this.modelClass.exists(id, options));
return ensurePromise(this.modelClass.exists(id, options)).catch(
handleDatabaseDriverError,
);
}

/**
Expand Down
Loading