diff --git a/lib/normalize-error.js b/lib/normalize-error.js new file mode 100644 index 00000000..6949b44e --- /dev/null +++ b/lib/normalize-error.js @@ -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; +}; diff --git a/lib/postgresql.js b/lib/postgresql.js index c850a51c..3bc8ce5a 100644 --- a/lib/postgresql.js +++ b/lib/postgresql.js @@ -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'); /** * @@ -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) { @@ -188,7 +197,7 @@ PostgreSQL.prototype.executeSQL = function(sql, params, options, callback) { result = data.rows; } } - callback(err ? err : null, result); + handleResponse(releaseCb, err, result); }); } @@ -196,12 +205,17 @@ PostgreSQL.prototype.executeSQL = function(sql, params, options, callback) { 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'); @@ -209,7 +223,10 @@ PostgreSQL.prototype.executeSQL = function(sql, params, options, callback) { 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 diff --git a/test/error-normalization.test.js b/test/error-normalization.test.js new file mode 100644 index 00000000..a6c88370 --- /dev/null +++ b/test/error-normalization.test.js @@ -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(); + }); + }); + }); + }); +});