Skip to content

Commit 3de300e

Browse files
author
Toni Sharpe
committed
feature(automatic-transactions): Wraps routes in transactions
* Uses hooks to handle transactions outside the route handler code * preHandler does BEGIN * onSend does COMMIT * onError does ROLLBACK * useTransaction routeOption gives the developer opt-in for this feature - they have to explicitly ask * Tests cover the four possibilities, a passing and failing set of queries, called in both true and false states for useTransaction For DX: * Adds `--fix` to the linting to help automate indenting etc. * Adds a `testonly` script which doesn't drop out for linting (helpful when iterating quickly over tests). resolves #75
1 parent 1c78d07 commit 3de300e

File tree

5 files changed

+269
-4
lines changed

5 files changed

+269
-4
lines changed

index.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,19 @@ function transact (fn, cb) {
5252
})
5353
}
5454

55+
// Re-usable code adds the handlers nicely
56+
const addHandler = (existingHandler, newHandler) => {
57+
if (Array.isArray(existingHandler)) {
58+
existingHandler.push(newHandler)
59+
} else if (typeof existingHandler === 'function') {
60+
existingHandler = [existingHandler, newHandler]
61+
} else {
62+
existingHandler = [newHandler]
63+
}
64+
65+
return existingHandler
66+
}
67+
5568
function fastifyPostgres (fastify, options, next) {
5669
let pg = defaultPg
5770

@@ -102,6 +115,54 @@ function fastifyPostgres (fastify, options, next) {
102115
}
103116
}
104117

118+
fastify.addHook('onRoute', routeOptions => {
119+
const useTransaction = routeOptions.useTransaction || (routeOptions.options && routeOptions.options.useTransaction)
120+
121+
if (useTransaction) {
122+
// This will rollback the transaction if the handler fails at some point
123+
const onError = async (req, reply, error) => {
124+
req.transactionFailed = true
125+
126+
try {
127+
await req.pg.query('ROLLBACK')
128+
} catch (err) {
129+
await req.pg.query('ROLLBACK')
130+
}
131+
}
132+
133+
routeOptions.onError = addHandler(routeOptions.onError, onError)
134+
}
135+
136+
const preHandler = async (req, reply) => {
137+
const client = await pool.connect()
138+
req.pg = client
139+
140+
if (useTransaction) {
141+
await req.pg.query('BEGIN')
142+
}
143+
}
144+
145+
// This will commit the transaction (or rollback if that fails) and also always
146+
// release the client, regardless of error state or useTransaction value
147+
const onSend = async (req, reply, payload) => {
148+
try {
149+
if (!req.transactionFailed && useTransaction) {
150+
await req.pg.query('COMMIT')
151+
}
152+
} catch (err) {
153+
if (useTransaction) {
154+
await req.pg.query('ROLLBACK')
155+
}
156+
} finally {
157+
req.pg.release()
158+
}
159+
}
160+
161+
// Add these handlers
162+
routeOptions.preHandler = addHandler(routeOptions.preHandler, preHandler)
163+
routeOptions.onSend = addHandler(routeOptions.onSend, onSend)
164+
})
165+
105166
next()
106167
}
107168

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
"main": "index.js",
66
"types": "index.d.ts",
77
"scripts": {
8-
"test": "standard && tap -J test/*.test.js && npm run test:typescript",
8+
"testonly": "tap -J test/*.test.js && npm run test:typescript",
9+
"test": "standard --fix && tap -J test/*.test.js && npm run test:typescript",
910
"test:typescript": "tsd",
1011
"test:report": "standard && tap -J --coverage-report=html test/*.test.js",
1112
"test:verbose": "standard && tap -J test/*.test.js -Rspec",

test/initialization.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ test('Should throw when trying to register multiple instances without giving a n
110110
})
111111
})
112112

113-
test('Should not throw when registering a named instance and an unnamed instance)', (t) => {
113+
test('Should not throw when registering a named instance and an unnamed instance', (t) => {
114114
t.plan(1)
115115

116116
const fastify = Fastify()
@@ -191,7 +191,7 @@ test('fastify.pg namespace should exist', (t) => {
191191
})
192192
})
193193

194-
test('fastify.pg.test namespace should exist', (t) => {
194+
test('fastify.pg custom namespace should exist if a name is set', (t) => {
195195
t.plan(6)
196196

197197
const fastify = Fastify()

test/query.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const t = require('tap')
44
const test = t.test
55
const Fastify = require('fastify')
66
const fastifyPostgres = require('../index')
7+
78
const {
89
BAD_DB_NAME,
910
connectionString,
@@ -134,7 +135,7 @@ test('When fastify.pg root namespace is used:', (t) => {
134135
t.end()
135136
})
136137

137-
test('When fastify.pg.test namespace is used:', (t) => {
138+
test('When fastify.pg custom namespace is used:', (t) => {
138139
t.test('Should be able to connect and perform a query', (t) => {
139140
t.plan(4)
140141

test/req-initialization.test.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
'use strict'
2+
3+
const t = require('tap')
4+
const test = t.test
5+
const Fastify = require('fastify')
6+
const fastifyPostgres = require('../index')
7+
const { connectionString } = require('./helpers')
8+
9+
// // A failed set of queries with transactions on, on test, NONE of these entries should be visible in the DB
10+
// fastify.get('/fail', { useTransaction: true }, async (req, reply) => {
11+
// console.log('in fail registration')
12+
13+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in-q1'])
14+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in-q2'])
15+
// await req.pg.query('INSERT INTO nope(username) VALUES($1) RETURNING id', ['fail-opt-in-q3'])
16+
17+
// reply.send('Fail example')
18+
// })
19+
20+
// // A passing set of queries with transactions on, on test, ALL of these entries should be visible in the DB
21+
// fastify.get('/pass', { useTransaction: true }, async (req, reply) => {
22+
// console.log('in pass registration')
23+
24+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in-q1'])
25+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in-q2'])
26+
27+
// reply.send('Pass example')
28+
// })
29+
30+
// // A failed set of queries with transactions off, on test, THE FIRST TWO of these entries should be visible in the DB
31+
// fastify.get('/fail-opt-out', { useTransaction: false }, async (req, reply) => {
32+
// console.log('in fail registration')
33+
34+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-out-q1'])
35+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-out-q2'])
36+
// await req.pg.query('INSERT INTO nope(username) VALUES($1) RETURNING id', ['fail-opt-out-q3'])
37+
38+
// reply.send('Fail example')
39+
// })
40+
41+
// // A passing set of queries with transactions off, on test, ALL of these entries should be visible in the DB
42+
// fastify.get('/pass-opt-out', { useTransaction: false }, async (req, reply) => {
43+
// console.log('in pass registration')
44+
45+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-out-q1'])
46+
// await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-out-q2'])
47+
48+
// reply.send('Pass example')
49+
// })
50+
51+
const extractUserCount = response => parseInt(JSON.parse(response.payload).rows[0].userCount)
52+
53+
test('fastify postgress useTransaction route option - ', t => {
54+
test('set to true - ', t => {
55+
test('passing queries provided', async t => {
56+
const fastify = Fastify()
57+
t.teardown(() => fastify.close())
58+
59+
await fastify.register(fastifyPostgres, {
60+
connectionString
61+
})
62+
63+
await fastify.pg.query('TRUNCATE users')
64+
65+
await fastify.get('/count-users', async (req, reply) => {
66+
const result = await req.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'pass-opt-in\'')
67+
68+
reply.send(result)
69+
})
70+
71+
await fastify.get('/pass', { useTransaction: true }, async (req, reply) => {
72+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in'])
73+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-in'])
74+
reply.send('complete')
75+
})
76+
77+
await fastify.inject({
78+
method: 'GET',
79+
url: '/pass'
80+
})
81+
82+
const response = await fastify.inject({
83+
method: 'GET',
84+
url: '/count-users'
85+
})
86+
87+
t.is(extractUserCount(response), 2)
88+
})
89+
test('failing queries provided', async t => {
90+
const fastify = Fastify()
91+
t.teardown(() => fastify.close())
92+
93+
await fastify.register(fastifyPostgres, {
94+
connectionString
95+
})
96+
97+
await fastify.pg.query('TRUNCATE users')
98+
99+
await fastify.get('/count-users', async (req, reply) => {
100+
const result = await req.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'fail-opt-in\'')
101+
102+
reply.send(result)
103+
})
104+
105+
await fastify.get('/fail', { useTransaction: true }, async (req, reply) => {
106+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in'])
107+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-in'])
108+
await req.pg.query('INSERT INTO nope(username) VALUES($1) RETURNING id', ['fail-opt-in'])
109+
reply.send('complete')
110+
})
111+
112+
await fastify.inject({
113+
method: 'GET',
114+
url: '/fail'
115+
})
116+
117+
const response = await fastify.inject({
118+
method: 'GET',
119+
url: '/count-users'
120+
})
121+
122+
t.is(extractUserCount(response), 0)
123+
})
124+
125+
t.end()
126+
})
127+
test('set to false - ', t => {
128+
test('passing queries provided', async t => {
129+
const fastify = Fastify()
130+
t.teardown(() => fastify.close())
131+
132+
await fastify.register(fastifyPostgres, {
133+
connectionString
134+
})
135+
136+
await fastify.pg.query('TRUNCATE users')
137+
138+
await fastify.get('/count-users', async (req, reply) => {
139+
const result = await req.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'pass-opt-out\'')
140+
141+
reply.send(result)
142+
})
143+
144+
await fastify.get('/pass-opt-out', { useTransaction: false }, async (req, reply) => {
145+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-out'])
146+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['pass-opt-out'])
147+
reply.send('complete')
148+
})
149+
150+
await fastify.inject({
151+
method: 'GET',
152+
url: '/pass-opt-out'
153+
})
154+
155+
const response = await fastify.inject({
156+
method: 'GET',
157+
url: '/count-users'
158+
})
159+
160+
t.is(extractUserCount(response), 2)
161+
})
162+
test('failing queries provided', async t => {
163+
const fastify = Fastify()
164+
t.teardown(() => fastify.close())
165+
166+
await fastify.register(fastifyPostgres, {
167+
connectionString
168+
})
169+
170+
await fastify.pg.query('TRUNCATE users')
171+
172+
await fastify.get('/count-users', async (req, reply) => {
173+
const result = await req.pg.query('SELECT COUNT(*) AS "userCount" FROM users WHERE username=\'fail-opt-out\'')
174+
175+
reply.send(result)
176+
})
177+
178+
await fastify.get('/fail-opt-out', { useTransaction: false }, async (req, reply) => {
179+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-out'])
180+
await req.pg.query('INSERT INTO users(username) VALUES($1) RETURNING id', ['fail-opt-out'])
181+
await req.pg.query('INSERT INTO nope(username) VALUES($1) RETURNING id', ['fail-opt-out'])
182+
reply.send('complete')
183+
})
184+
185+
await fastify.inject({
186+
method: 'GET',
187+
url: '/fail-opt-out'
188+
})
189+
190+
const response = await fastify.inject({
191+
method: 'GET',
192+
url: '/count-users'
193+
})
194+
195+
t.is(extractUserCount(response), 2)
196+
})
197+
198+
t.end()
199+
})
200+
201+
t.end()
202+
})

0 commit comments

Comments
 (0)