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
65 changes: 65 additions & 0 deletions lib/normalize-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use strict';

/**
* Normalizes PostgreSQL-specific error codes (SQLSTATE) to standard, protocol-neutral domain codes.
*/
module.exports = function(err) {
if (!err || !err.code) return err;

// PostgreSQL uses 5-character SQLSTATE error codes
switch (err.code) {
// 1. Data Integrity & Constraints
case '23505': // unique_violation
err.code = 'UNIQUE_CONSTRAINT_VIOLATION';
break;
case '23503': // foreign_key_violation
case '23P01': // exclusion_violation
err.code = 'FOREIGN_KEY_VIOLATION';
break;
case '23502': // not_null_violation
err.code = 'NOT_NULL_VIOLATION';
break;
case '23514': // check_violation
err.code = 'CHECK_CONSTRAINT_VIOLATION';
break;

// 2. Schema & Object Definitions
case '42P01': // undefined_table
err.code = 'TABLE_NOT_FOUND';
break;
case '42703': // undefined_column
err.code = 'COLUMN_NOT_FOUND';
break;

// 3. Data Formatting & Types
case '22001': // string_data_right_truncation
case '22003': // numeric_value_out_of_range
case '22P02': // invalid_text_representation
err.code = 'DATA_TYPE_MISMATCH';
break;

// 4. Concurrency & Execution
case '40001': // serialization_failure
case '40P01': // deadlock_detected
case '55P03': // lock_not_available
err.code = 'LOCK_CONFLICT';
break;
case '57014': // query_canceled
err.code = 'QUERY_TIMEOUT';
break;
case '42601': // syntax_error
err.code = 'SYNTAX_ERROR';
break;

// 5. Infrastructure & Connection
case '08000': // connection_exception
case '08003': // connection_does_not_exist
case '08006': // connection_failure
case '57P01': // admin_shutdown
case '57P02': // crash_shutdown
case '57P03': // cannot_connect_now
err.code = 'CONNECTION_FAILURE';
break;
}
return err;
};
33 changes: 25 additions & 8 deletions lib/postgresql.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const debug = require('debug')('loopback:connector:postgresql');
const debugData = require('debug')('loopback:connector:postgresql:data');
const debugSort = require('debug')('loopback:connector:postgresql:order');
const Promise = require('bluebird');
const normalizePostgreSQLError = require('./normalize-error');

/**
*
Expand Down Expand Up @@ -164,15 +165,23 @@ PostgreSQL.prototype.executeSQL = function(sql, params, options, callback) {
debug('SQL: %s', sql);
}

function handleResponse(releaseCb, err, result) {
if (releaseCb) {
releaseCb(err);
}
if (err) {
debug('Raw driver error intercepted: %O', err);
// Transform raw PostgreSQL driver error into standard LoopBack DB error
err = normalizePostgreSQLError(err);
}
callback(err, result);
}

function executeWithConnection(connection, releaseCb) {
connection.query(sql, params, function(err, data) {
// if(err) console.error(err);
if (err) debug(err);
if (data) debugData('%j', data);
// Release the connection back to the pool.
if (releaseCb) {
releaseCb(err);
}
let result = null;
if (data) {
switch (data.command) {
Expand All @@ -188,28 +197,36 @@ PostgreSQL.prototype.executeSQL = function(sql, params, options, callback) {
result = data.rows;
}
}
callback(err ? err : null, result);
handleResponse(releaseCb, err, result);
});
}

const transaction = options.transaction;
if (transaction && transaction.connector === this) {
if (!transaction.connection) {
return process.nextTick(function() {
callback(new Error(g.f('Connection does not exist')));
const err = new Error(g.f('Transaction is not active'));
err.code = 'TRANSACTION_NOT_ACTIVE';
callback(err);
});
}

if (transaction.txId !== transaction.connection.txId) {
return process.nextTick(function() {
callback(new Error(g.f('Transaction is not active')));
const err = new Error(g.f('Transaction is not active'));
err.code = 'TRANSACTION_NOT_ACTIVE';
callback(err);
});
}
debug('Execute SQL within a transaction');
// Do not release the connection
executeWithConnection(transaction.connection, null);
} else {
self.pg.connect(function(err, connection, releaseCb) {
if (err) return callback(err);
if (err) {
debug('Raw driver error intercepted: %O', err);
return callback(normalizePostgreSQLError(err));
}
// pg-pool requires attaching an error handler to each item checked out off the pool,
// which will handle connection-level errors.
// See https://github.com/brianc/node-postgres/issues/1324
Expand Down
164 changes: 164 additions & 0 deletions test/error-normalization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use strict';
require('./init.js');
const should = require('should');

let db, ErrorModel;
const normalizePostgreSQLError = require('../lib/normalize-error.js');

describe('postgresql error normalization unit tests', function() {
it('normalizes unique constraint violations (23505)', function() {
const rawErr = new Error('duplicate key value violates unique constraint');
rawErr.code = '23505';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'UNIQUE_CONSTRAINT_VIOLATION');
});

it('normalizes undefined table errors (42P01)', function() {
const rawErr = new Error('relation "foo" does not exist');
rawErr.code = '42P01';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'TABLE_NOT_FOUND');
});

it('normalizes undefined column errors (42703)', function() {
const rawErr = new Error('column "non_existent" does not exist');
rawErr.code = '42703';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'COLUMN_NOT_FOUND');
});

it('normalizes syntax errors (42601)', function() {
const rawErr = new Error('syntax error at or near "SELCT"');
rawErr.code = '42601';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'SYNTAX_ERROR');
});

it('normalizes query timeout errors (57014)', function() {
const rawErr = new Error('canceling statement due to statement timeout');
rawErr.code = '57014';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'QUERY_TIMEOUT');
});

it('normalizes lock conflict and deadlock errors (40P01, 40001, 55P03)', function() {
['40P01', '40001', '55P03'].forEach(code => {
const rawErr = new Error('lock conflict / deadlock');
rawErr.code = code;
const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'LOCK_CONFLICT');
});
});

it('normalizes not-null violations (23502)', function() {
const rawErr = new Error('null value in column violates not-null constraint');
rawErr.code = '23502';

const normalized = normalizePostgreSQLError(rawErr);
normalized.should.have.property('code', 'NOT_NULL_VIOLATION');
});
});

describe('postgresql error normalization', function() {
before(function(done) {
db = global.getDataSource(true);
// Explicitly configure unique index so automigrate creates the constraint in Postgres
ErrorModel = db.define('ErrorModel', {
name: {type: String, index: {unique: true}, required: true},
});
db.automigrate('ErrorModel', done);
});

beforeEach(function(done) {
ErrorModel.destroyAll(done);
});

it('normalizes unique constraint violations (23505 -> UNIQUE_CONSTRAINT_VIOLATION)', function(done) {
ErrorModel.create({name: 'test-unique'}, function(err) {
if (err) return done(err);
// Attempt to insert duplicate record
ErrorModel.create({name: 'test-unique'}, function(err) {
should.exist(err);
err.should.have.property('code', 'UNIQUE_CONSTRAINT_VIOLATION');
done();
});
});
});

it('normalizes table not found errors (42P01 -> TABLE_NOT_FOUND)', function(done) {
db.connector.executeSQL(
'SELECT * FROM "non_existent_table_xyz"',
[],
{},
function(err, results) {
should.exist(err);
err.should.have.property('code', 'TABLE_NOT_FOUND');
done();
},
);
});

it('normalizes column not found errors (42703 -> COLUMN_NOT_FOUND)', function(done) {
db.connector.executeSQL(
'SELECT "non_existent_column" FROM "errormodel"',
[],
{},
function(err) {
should.exist(err);
err.should.have.property('code', 'COLUMN_NOT_FOUND');
done();
},
);
});

it('normalizes syntax errors (42601 -> SYNTAX_ERROR)', function(done) {
db.connector.executeSQL(
'SELCT * FROM "errormodel"',
[],
{},
function(err) {
should.exist(err);
err.should.have.property('code', 'SYNTAX_ERROR');
done();
},
);
});

it('normalizes not-null constraint violations (23502 -> NOT_NULL_VIOLATION)', function(done) {
// Bypass model validation by executing raw SQL or using connector directly
db.connector.executeSQL(
'INSERT INTO "errormodel" ("name") VALUES ($1)',
[null],
{},
function(err) {
should.exist(err);
err.should.have.property('code', 'NOT_NULL_VIOLATION');
done();
},
);
});

it('normalizes inactive transaction or connection errors at the connector level', function(done) {
const Transaction = require('loopback-connector').Transaction;
Transaction.begin(db.connector, function(err, tx) {
if (err) return done(err);

// Force commit the transaction so its connection is cleared
tx.commit(function(err) {
if (err) return done(err);

// Call executeSQL directly to exercise the connector's internal check
db.connector.executeSQL('SELECT 1', [], {transaction: tx}, function(err) {
should.exist(err);
err.should.have.property('code', 'TRANSACTION_NOT_ACTIVE');
done();
});
});
});
});
});
Loading