diff --git a/README.md b/README.md index 9368601..cd73866 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,33 @@ fastify.listen(3000, err => { }) ``` +### Transact route option +It's possible to automatically wrap a route handler in a transaction by using the `transact` option when registering a route with Fastify. Note that the option must be scoped within a `pg` options object to take effect. + +`query` commands can then be accessed at `request.pg` or `request.pg[name]` and `transact` can be set for either the root pg client with value `true` or for a pg client at a particular namespace with value `name`. Note that the namespace needs to be set when registering the plugin in order to be available on the request object. + +```js +// transact set for the route pg client +fastify.get('/user/:id', { pg: { transact: true } }, (req, reply) => { + // transaction wrapped queries, NO error handling + req.pg.query('SELECT username FROM users WHERE id=1') + req.pg.query('SELECT username FROM users WHERE id=2') + req.pg.query('SELECT username FROM users WHERE id=3') +}) + +// transact set for a pg client at name +fastify.get('/user/:id', { pg: { transact: 'foo' } }, (req, reply) => { + // transaction wrapped queries, NO error handling + req.pg.foo.query('SELECT username FROM users WHERE id=1') + req.pg.foo.query('SELECT username FROM users WHERE id=2') + req.pg.foo.query('SELECT username FROM users WHERE id=3') +}) +``` + +Important: rolling back a transaction relies on the handler failing and being caught by an `onError` hook. This means that the transaction wrapped route handler must not catch any errors internally. + +In the plugin this works by using the `preHandler` hook to open the transaction, then the `onError` and `onSend` hooks to commit or rollback and release the client back to the pool. + ## TypeScript Usage Install the compiler and typings for pg module: diff --git a/index.js b/index.js index 37ab6cd..1d26d18 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,10 @@ const defaultPg = require('pg') const fp = require('fastify-plugin') +const addHandler = require('./lib/add-handler.js') + +const transactionFailedSymbol = Symbol('transactionFailed') + function transactionUtil (pool, fn, cb) { pool.connect((err, client, done) => { if (err) return cb(err) @@ -52,6 +56,18 @@ function transact (fn, cb) { }) } +function extractRequestClient (req, transact) { + if (typeof transact !== 'string') { + return req.pg + } + + const requestClient = req.pg[transact] + if (!requestClient) { + throw new Error(`request client '${transact}' does not exist`) + } + return requestClient +} + function fastifyPostgres (fastify, options, next) { let pg = defaultPg @@ -102,6 +118,70 @@ function fastifyPostgres (fastify, options, next) { } } + if (!fastify.hasRequestDecorator('pg')) { + fastify.decorateRequest('pg', null) + } + + fastify.addHook('onRoute', routeOptions => { + const transact = routeOptions && routeOptions.pg && routeOptions.pg.transact + + if (!transact) { + return + } + if (typeof transact === 'string' && transact !== name) { + return + } + if (name && transact === true) { + return + } + + const preHandler = async (req, reply) => { + const client = await pool.connect() + + if (name) { + if (!req.pg) { + req.pg = {} + } + + if (client[name]) { + throw new Error(`pg client '${name}' is a reserved keyword`) + } else if (req.pg[name]) { + throw new Error(`request client '${name}' has already been registered`) + } + + req.pg[name] = client + } else { + if (req.pg) { + throw new Error('request client has already been registered') + } else { + req.pg = client + } + } + + client.query('BEGIN') + } + + const onError = (req, reply, error, done) => { + req[transactionFailedSymbol] = true + extractRequestClient(req, transact).query('ROLLBACK', done) + } + + const onSend = async (req) => { + const requestClient = extractRequestClient(req, transact) + try { + if (!req[transactionFailedSymbol]) { + await requestClient.query('COMMIT') + } + } finally { + requestClient.release() + } + } + + routeOptions.preHandler = addHandler(routeOptions.preHandler, preHandler) + routeOptions.onError = addHandler(routeOptions.onError, onError) + routeOptions.onSend = addHandler(routeOptions.onSend, onSend) + }) + next() } diff --git a/lib/add-handler.js b/lib/add-handler.js new file mode 100644 index 0000000..f7b8a74 --- /dev/null +++ b/lib/add-handler.js @@ -0,0 +1,14 @@ +'use strict' + +module.exports = function addHandler (existingHandler, newHandler) { + if (Array.isArray(existingHandler)) { + return [ + ...existingHandler, + newHandler + ] + } else if (typeof existingHandler === 'function') { + return [existingHandler, newHandler] + } else { + return [newHandler] + } +} diff --git a/test/add-handler.test.js b/test/add-handler.test.js new file mode 100644 index 0000000..1ae4eba --- /dev/null +++ b/test/add-handler.test.js @@ -0,0 +1,43 @@ +'use strict' + +const t = require('tap') +const test = t.test +const addHandler = require('../lib/add-handler') + +test('addHandler - ', t => { + test('when existing handler is not defined', t => { + t.plan(1) + + const handlers = addHandler( + undefined, + 'test' + ) + + t.same(handlers, ['test']) + }) + test('when existing handler is a array', t => { + t.plan(1) + + const handlers = addHandler( + ['test'], + 'again' + ) + + t.same(handlers, ['test', 'again']) + }) + test('when existing handler is a function', t => { + t.plan(2) + + const stub = () => 'test' + + const handlers = addHandler( + stub, + 'again' + ) + + t.same(handlers[0](), 'test') + t.same(handlers[1], 'again') + }) + + t.end() +}) diff --git a/test/initialization.test.js b/test/initialization.test.js index b6ae3d0..f17a5e9 100644 --- a/test/initialization.test.js +++ b/test/initialization.test.js @@ -110,7 +110,7 @@ test('Should throw when trying to register multiple instances without giving a n }) }) -test('Should not throw when registering a named instance and an unnamed instance)', (t) => { +test('Should not throw when registering a named instance and an unnamed instance', (t) => { t.plan(1) const fastify = Fastify() @@ -191,7 +191,7 @@ test('fastify.pg namespace should exist', (t) => { }) }) -test('fastify.pg.test namespace should exist', (t) => { +test('fastify.pg custom namespace should exist if a name is set', (t) => { t.plan(6) const fastify = Fastify() diff --git a/test/query.test.js b/test/query.test.js index 46c5046..e726b6a 100644 --- a/test/query.test.js +++ b/test/query.test.js @@ -4,6 +4,7 @@ const t = require('tap') const test = t.test const Fastify = require('fastify') const fastifyPostgres = require('../index') + const { BAD_DB_NAME, connectionString, @@ -134,7 +135,7 @@ test('When fastify.pg root namespace is used:', (t) => { t.end() }) -test('When fastify.pg.test namespace is used:', (t) => { +test('When fastify.pg custom namespace is used:', (t) => { t.test('Should be able to connect and perform a query', (t) => { t.plan(4) diff --git a/test/req-initialization.test.js b/test/req-initialization.test.js new file mode 100644 index 0000000..15f194f --- /dev/null +++ b/test/req-initialization.test.js @@ -0,0 +1,197 @@ +'use strict' + +const t = require('tap') +const test = t.test +const Fastify = require('fastify') +const fastifyPostgres = require('../index') +const { connectionString } = require('./helpers') + +const extractUserCount = response => parseInt(JSON.parse(response.payload).rows[0].userCount) + +test('fastify postgress useTransaction route option', t => { + test('queries that succeed provided', async t => { + const fastify = Fastify() + t.teardown(() => fastify.close()) + + await fastify.register(fastifyPostgres, { + connectionString + }) + + await fastify.pg.query('TRUNCATE users') + + fastify.get('/count-users', async (req, reply) => { + const result = await fastify.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'pass-opt-in\'') + + reply.send(result) + }) + + fastify.get('/pass', { pg: { transact: true } }, async (req, reply) => { + await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in']) + await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in']) + reply.send('complete') + }) + + await fastify.inject({ url: '/pass' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/count-users' + }) + + t.is(extractUserCount(response), 2) + }) + test('queries that succeed provided to a namespace', async t => { + const fastify = Fastify() + t.teardown(() => fastify.close()) + + await fastify.register(fastifyPostgres, { + connectionString, + name: 'test' + }) + + await fastify.pg.test.query('TRUNCATE users') + + fastify.get('/count-users', async (req, reply) => { + const result = await fastify.pg.test.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'pass-opt-in\'') + + reply.send(result) + }) + + fastify.get('/pass', { pg: { transact: 'test' } }, async (req, reply) => { + await req.pg.test.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in']) + await req.pg.test.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in']) + + reply.send('complete') + }) + + await fastify.inject({ url: '/pass' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/count-users' + }) + + t.is(extractUserCount(response), 2) + }) + test('queries that fail provided', async t => { + const fastify = Fastify() + t.teardown(() => fastify.close()) + + await fastify.register(fastifyPostgres, { + connectionString + }) + + await fastify.pg.query('TRUNCATE users') + + fastify.get('/count-users', async (req, reply) => { + const result = await fastify.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'fail-opt-in\'') + + reply.send(result) + }) + + fastify.get('/fail', { pg: { transact: true } }, async (req, reply) => { + await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in']) + await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in']) + await req.pg.query('INSERT INTO nope(username) VALUES($1) RETURNING id', ['fail-opt-in']) + reply.send('complete') + }) + + await fastify.inject({ url: '/fail' }) + + const response = await fastify.inject({ + method: 'GET', + url: '/count-users' + }) + + t.is(extractUserCount(response), 0) + }) + + t.end() +}) + +test('combinations of registrationOptions.name and routeOptions.pg.transact that should not add hooks', t => { + test('transact not set', t => { + t.plan(1) + + const fastify = Fastify() + t.teardown(() => fastify.close()) + + fastify.register(fastifyPostgres, { + connectionString + }) + + fastify.get('/', (req, reply) => { + t.is(req.pg, null) + }) + + fastify.inject({ url: '/' }) + }) + test('name set and transact not set', t => { + t.plan(1) + + const fastify = Fastify() + t.teardown(() => fastify.close()) + + fastify.register(fastifyPostgres, { + connectionString, + name: 'test' + }) + + fastify.get('/', (req, reply) => { + t.is(req.pg, null) + }) + + fastify.inject({ url: '/' }) + }) + test('name set and transact set to true', t => { + t.plan(1) + + const fastify = Fastify() + t.teardown(() => fastify.close()) + + fastify.register(fastifyPostgres, { + connectionString, + name: 'test' + }) + + fastify.get('/', { pg: { transact: true } }, (req, reply) => { + t.is(req.pg, null) + }) + + fastify.inject({ url: '/' }) + }) + test('name not set and transact set to string', t => { + t.plan(1) + + const fastify = Fastify() + t.teardown(() => fastify.close()) + + fastify.register(fastifyPostgres, { + connectionString + }) + + fastify.get('/', { pg: { transact: 'test' } }, (req, reply) => { + t.is(req.pg, null) + }) + + fastify.inject({ url: '/' }) + }) + test('name and transact set to different strings', t => { + t.plan(1) + + const fastify = Fastify() + t.teardown(() => fastify.close()) + + fastify.register(fastifyPostgres, { + connectionString, + name: 'test' + }) + + fastify.get('/', { pg: { transact: 'different' } }, (req, reply) => { + t.is(req.pg, null) + }) + + fastify.inject({ url: '/' }) + }) + t.end() +})