Skip to content

feature(automatic-transactions): Creates a decorator to make transactions easier for the developer #76

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 24, 2021
Merged
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
80 changes: 80 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

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

Expand Down
14 changes: 14 additions & 0 deletions lib/add-handler.js
Original file line number Diff line number Diff line change
@@ -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]
}
}
43 changes: 43 additions & 0 deletions test/add-handler.test.js
Original file line number Diff line number Diff line change
@@ -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()
})
4 changes: 2 additions & 2 deletions test/initialization.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion test/query.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
Loading