@proteinjs/db-driver-knex 1.14.1 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +5 -3
- package/dist/generated/index.js +1 -1
- package/dist/generated/index.js.map +1 -1
- package/dist/generated/test/index.js +1 -1
- package/dist/generated/test/index.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/src/KnexConfig.d.ts +8 -0
- package/dist/src/KnexConfig.d.ts.map +1 -1
- package/dist/src/KnexDriver.d.ts +8 -7
- package/dist/src/KnexDriver.d.ts.map +1 -1
- package/dist/src/KnexDriver.js +52 -36
- package/dist/src/KnexDriver.js.map +1 -1
- package/dist/src/KnexFailureLine.d.ts +58 -0
- package/dist/src/KnexFailureLine.d.ts.map +1 -0
- package/dist/src/KnexFailureLine.js +124 -0
- package/dist/src/KnexFailureLine.js.map +1 -0
- package/dist/test/LogValuesSwitch.test.js +13 -7
- package/dist/test/LogValuesSwitch.test.js.map +1 -1
- package/dist/test/OperationDeadline.test.d.ts +2 -0
- package/dist/test/OperationDeadline.test.d.ts.map +1 -0
- package/dist/test/OperationDeadline.test.js +166 -0
- package/dist/test/OperationDeadline.test.js.map +1 -0
- package/dist/test/StatementLogNeverThrows.test.js +23 -29
- package/dist/test/StatementLogNeverThrows.test.js.map +1 -1
- package/dist/test/StatementParamLogging.test.js +16 -41
- package/dist/test/StatementParamLogging.test.js.map +1 -1
- package/dist/test/VendorTextNeverOnALine.test.d.ts +2 -0
- package/dist/test/VendorTextNeverOnALine.test.d.ts.map +1 -0
- package/dist/test/VendorTextNeverOnALine.test.js +330 -0
- package/dist/test/VendorTextNeverOnALine.test.js.map +1 -0
- package/generated/index.ts +1 -1
- package/generated/test/index.ts +1 -1
- package/index.ts +1 -0
- package/package.json +5 -5
- package/src/KnexConfig.ts +8 -0
- package/src/KnexDriver.ts +36 -37
- package/src/KnexFailureLine.ts +127 -0
- package/test/LogValuesSwitch.test.ts +3 -1
- package/test/OperationDeadline.test.ts +118 -0
- package/test/StatementLogNeverThrows.test.ts +24 -20
- package/test/StatementParamLogging.test.ts +12 -17
- package/test/VendorTextNeverOnALine.test.ts +257 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import knex from 'knex';
|
|
2
|
+
import { KnexDriver } from '@proteinjs/db-driver-knex';
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The driver's per-operation deadline: every query and DML statement runs under it, and the driver
|
|
7
|
+
* reports it (`DbDriver.getOperationDeadlineMs`) — so code that sizes a wait by the driver's
|
|
8
|
+
* deadline is sized by a bound the driver actually enforces. A statement the server never answers
|
|
9
|
+
* fails AT the deadline with an error naming it, and the statement is cancelled on the server
|
|
10
|
+
* (`KILL QUERY` naming the connection it runs on), instead of holding its connection indefinitely.
|
|
11
|
+
*
|
|
12
|
+
* No server is needed: the REAL query layer and dialect run over a stub connection that answers
|
|
13
|
+
* transaction control and `KILL QUERY`, and never answers any other statement.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
type DriverStatics = { KNEX?: unknown };
|
|
17
|
+
type DriverInternals = { logger: Logger };
|
|
18
|
+
|
|
19
|
+
/** The thread ids of the connections a statement was left unanswered on. */
|
|
20
|
+
const unanswered: number[] = [];
|
|
21
|
+
/** Every `KILL QUERY` the stub server received, by the thread id it named. */
|
|
22
|
+
const killed: number[] = [];
|
|
23
|
+
let nextThreadId = 100;
|
|
24
|
+
|
|
25
|
+
/** A connection whose server never answers a statement — until it is killed. */
|
|
26
|
+
const silentConnection = () => {
|
|
27
|
+
const threadId = nextThreadId++;
|
|
28
|
+
return {
|
|
29
|
+
threadId,
|
|
30
|
+
state: 'authenticated',
|
|
31
|
+
on: () => undefined,
|
|
32
|
+
removeAllListeners: () => undefined,
|
|
33
|
+
connect: (done: (error?: Error) => void) => done(),
|
|
34
|
+
end: (done: (error?: Error) => void) => done(),
|
|
35
|
+
query: (options: { sql: string }, bindings: any[], done: (error: Error | null, rows?: unknown[]) => void) => {
|
|
36
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(options.sql)) {
|
|
37
|
+
done(null, []);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (/^\s*KILL QUERY/i.test(options.sql)) {
|
|
41
|
+
killed.push(bindings[0]);
|
|
42
|
+
done(null, []);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// Never answered: the statement a dead link or a stuck server leaves in flight.
|
|
46
|
+
unanswered.push(threadId);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const config = { host: 'localhost', user: 'root', password: '', dbName: 'test' };
|
|
52
|
+
const statement = () => ({ sql: 'SELECT `id` FROM `row` WHERE `id` = ?', params: ['row-1'] });
|
|
53
|
+
|
|
54
|
+
describe('The knex driver`s per-operation deadline: reported, and enforced on every statement', () => {
|
|
55
|
+
const statics = KnexDriver as unknown as DriverStatics;
|
|
56
|
+
let instance: ReturnType<typeof knex>;
|
|
57
|
+
let previousInstance: unknown;
|
|
58
|
+
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
instance = knex({ client: 'mysql', connection: { host: 'localhost', user: 'root', password: '' } });
|
|
61
|
+
// The dialect's own connection lifecycle, over a stub client library.
|
|
62
|
+
(instance as any).client.driver = { createConnection: silentConnection };
|
|
63
|
+
previousInstance = statics.KNEX;
|
|
64
|
+
statics.KNEX = instance;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(async () => {
|
|
68
|
+
statics.KNEX = previousInstance;
|
|
69
|
+
await instance.destroy();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
unanswered.length = 0;
|
|
74
|
+
killed.length = 0;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/** A driver with `deadlineMs`, its failure lines kept off the test output. */
|
|
78
|
+
const driverWith = (deadlineMs: number) => {
|
|
79
|
+
const driver = new KnexDriver({ ...config, operationDeadlineMs: deadlineMs });
|
|
80
|
+
(driver as unknown as DriverInternals).logger = new Logger({
|
|
81
|
+
name: 'KnexDriver',
|
|
82
|
+
logWriter: { write: () => undefined } as any,
|
|
83
|
+
});
|
|
84
|
+
return driver;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
test('the driver reports the deadline it is configured with, and 60 s when none is configured', () => {
|
|
88
|
+
expect(driverWith(500).getOperationDeadlineMs()).toBe(500);
|
|
89
|
+
expect(new KnexDriver(config).getOperationDeadlineMs()).toBe(60_000);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a statement the server never answers fails AT the deadline, naming it, and is cancelled on the server', async () => {
|
|
93
|
+
const driver = driverWith(500);
|
|
94
|
+
|
|
95
|
+
const before = Date.now();
|
|
96
|
+
await expect(driver.runQuery(statement)).rejects.toThrow(/Defined query timeout of 500ms exceeded/);
|
|
97
|
+
const elapsed = Date.now() - before;
|
|
98
|
+
|
|
99
|
+
expect(elapsed).toBeGreaterThanOrEqual(450);
|
|
100
|
+
expect(elapsed).toBeLessThan(3_000);
|
|
101
|
+
// The cancel named the connection the statement was left on.
|
|
102
|
+
expect(unanswered).toHaveLength(1);
|
|
103
|
+
expect(killed).toEqual(unanswered);
|
|
104
|
+
}, 10_000);
|
|
105
|
+
|
|
106
|
+
test('a statement inside a transaction runs under the same deadline, and the transaction fails with it', async () => {
|
|
107
|
+
const driver = driverWith(500);
|
|
108
|
+
|
|
109
|
+
const before = Date.now();
|
|
110
|
+
await expect(driver.runTransaction((transaction) => driver.runDml(statement, transaction))).rejects.toThrow(
|
|
111
|
+
/Defined query timeout of 500ms exceeded/
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(Date.now() - before).toBeLessThan(3_000);
|
|
115
|
+
expect(unanswered).toHaveLength(1);
|
|
116
|
+
expect(killed).toEqual(unanswered);
|
|
117
|
+
}, 10_000);
|
|
118
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import knex from 'knex';
|
|
2
2
|
import { inspect } from 'util';
|
|
3
|
-
import { KnexDriver } from '@proteinjs/db-driver-knex';
|
|
3
|
+
import { KnexDriver, KnexFailureLine } from '@proteinjs/db-driver-knex';
|
|
4
4
|
import { Logger } from '@proteinjs/logger';
|
|
5
5
|
import { CapturedLog, lineOf } from './util/printedLine';
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ import { CapturedLog, lineOf } from './util/printedLine';
|
|
|
12
12
|
*
|
|
13
13
|
* Two layers hold that, and this suite holds both:
|
|
14
14
|
*
|
|
15
|
-
* - the helpers that build the line (`describeParams`, `
|
|
15
|
+
* - the helpers that build the line (`describeParams`, `KnexFailureLine.causeOf`) are TOTAL: whatever they
|
|
16
16
|
* are handed — positional bindings, a bindings dictionary, nothing, null, a scalar, a Map, a
|
|
17
17
|
* value whose accessor or `toJSON` throws, a proxy that refuses everything — they return a
|
|
18
18
|
* description and never a value;
|
|
@@ -34,7 +34,6 @@ type ParamsDescription = ParamDescription[] | { [name: string]: ParamDescription
|
|
|
34
34
|
type DriverInternals = {
|
|
35
35
|
logger: Logger;
|
|
36
36
|
describeParams: (params?: unknown) => ParamsDescription | undefined;
|
|
37
|
-
causeSummary: (error: unknown) => { [fact: string]: unknown };
|
|
38
37
|
};
|
|
39
38
|
type DriverStatics = { KNEX?: unknown };
|
|
40
39
|
|
|
@@ -164,13 +163,8 @@ describe('describeParams is total: any bindings, a description, never a value',
|
|
|
164
163
|
});
|
|
165
164
|
});
|
|
166
165
|
|
|
167
|
-
describe('
|
|
168
|
-
const
|
|
169
|
-
host: 'localhost',
|
|
170
|
-
user: 'root',
|
|
171
|
-
password: '',
|
|
172
|
-
dbName: 'test',
|
|
173
|
-
}) as unknown as DriverInternals;
|
|
166
|
+
describe('KnexFailureLine.causeOf is total: anything thrown, a summary of codes and the driver`s sentence, never the vendor`s text', () => {
|
|
167
|
+
const UNNAMED = 'the database refused the statement';
|
|
174
168
|
const vendorFacts = {
|
|
175
169
|
code: 'ER_NO_SUCH_TABLE',
|
|
176
170
|
errno: 1146,
|
|
@@ -182,27 +176,37 @@ describe('causeSummary is total: anything thrown, a summary of codes, never the
|
|
|
182
176
|
[
|
|
183
177
|
'a vendor error whose message and sql carry the bindings',
|
|
184
178
|
() => Object.assign(new Error(`insert ... '${CANARY}'`), vendorFacts, { sql: `insert ... '${CANARY}'` }),
|
|
185
|
-
{
|
|
179
|
+
{
|
|
180
|
+
name: 'Error',
|
|
181
|
+
code: 'ER_NO_SUCH_TABLE',
|
|
182
|
+
errno: 1146,
|
|
183
|
+
sqlState: '42S02',
|
|
184
|
+
message: 'a table the statement names does not exist',
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
['nothing', () => undefined, { message: UNNAMED }],
|
|
188
|
+
['null', () => null, { message: UNNAMED }],
|
|
189
|
+
['a thrown string', () => CANARY, { message: UNNAMED }],
|
|
190
|
+
['a thrown number', () => 1146, { message: UNNAMED }],
|
|
191
|
+
[
|
|
192
|
+
'facts of the wrong kind',
|
|
193
|
+
() => ({ name: 5, code: 1146, errno: '1146', sqlState: null, sqlMessage: {} }),
|
|
194
|
+
{ message: UNNAMED },
|
|
186
195
|
],
|
|
187
|
-
['nothing', () => undefined, {}],
|
|
188
|
-
['null', () => null, {}],
|
|
189
|
-
['a thrown string', () => CANARY, {}],
|
|
190
|
-
['a thrown number', () => 1146, {}],
|
|
191
|
-
['facts of the wrong kind', () => ({ name: 5, code: 1146, errno: '1146', sqlState: null, sqlMessage: {} }), {}],
|
|
192
196
|
[
|
|
193
197
|
'an error with a fact whose accessor throws',
|
|
194
198
|
() => throwingEntry(Object.assign(new Error(CANARY), vendorFacts), 'code'),
|
|
195
|
-
{ name: 'Error', errno: 1146, sqlState: '42S02',
|
|
199
|
+
{ name: 'Error', errno: 1146, sqlState: '42S02', message: UNNAMED },
|
|
196
200
|
],
|
|
197
|
-
['an error that refuses every read', () => refusesEverything(new Error(CANARY)), {}],
|
|
198
|
-
['a revoked error', () => revoked(new Error(CANARY)), {}],
|
|
201
|
+
['an error that refuses every read', () => refusesEverything(new Error(CANARY)), { message: UNNAMED }],
|
|
202
|
+
['a revoked error', () => revoked(new Error(CANARY)), { message: UNNAMED }],
|
|
199
203
|
];
|
|
200
204
|
|
|
201
205
|
test.each(THROWN)('%s', (_shape, thrown, expected) => {
|
|
202
206
|
let summary: { [fact: string]: unknown } | undefined;
|
|
203
207
|
|
|
204
208
|
expect(() => {
|
|
205
|
-
summary =
|
|
209
|
+
summary = KnexFailureLine.causeOf(thrown());
|
|
206
210
|
}).not.toThrow();
|
|
207
211
|
|
|
208
212
|
expect(summary).toEqual(expected);
|
|
@@ -14,8 +14,8 @@ import { CapturedLog, lineOf } from './util/printedLine';
|
|
|
14
14
|
* The contract: the failure line carries the SQL text (placeholders only), the parameters
|
|
15
15
|
* DESCRIBED — each one's position, the kind of its value and, for strings, arrays and bytes, its
|
|
16
16
|
* length; one private helper (`describeParams`) owns that form — and a summary of the failure (the
|
|
17
|
-
* error's name, the vendor's codes, the
|
|
18
|
-
* rides the line. (The dev-only switch that adds the values back is LogValuesSwitch.test.ts; it is
|
|
17
|
+
* error's name, the vendor's codes, the driver's own sentence — KnexFailureLine). The vendor error
|
|
18
|
+
* itself no longer rides the line. (The dev-only switch that adds the values back is LogValuesSwitch.test.ts; it is
|
|
19
19
|
* off here.) A statement's bindings are typed as an array, but the query layer also accepts a
|
|
20
20
|
* bindings DICTIONARY (`:name` placeholders) and the driver forwards what it was handed: a
|
|
21
21
|
* dictionary is described by entry name. (Every other shape the driver can be handed, and a log
|
|
@@ -26,13 +26,10 @@ import { CapturedLog, lineOf } from './util/printedLine';
|
|
|
26
26
|
* that prints what it catches still prints the interpolated SQL; that is the query layer's
|
|
27
27
|
* behaviour and this contract does not change it.
|
|
28
28
|
*
|
|
29
|
-
* SCOPE, by name: this suite holds what the DRIVER prints of the parameters it was handed.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* work is parked on the branch `fix/driver-logs-never-carry-param-values`, pending a decision. So
|
|
34
|
-
* the canaries below are bound to parameters the server does not echo, and the one value it does
|
|
35
|
-
* echo — the colliding key — is left out of the assertions on purpose.
|
|
29
|
+
* SCOPE, by name: this suite holds what the DRIVER prints of the parameters it was handed. The
|
|
30
|
+
* vendor-echo class — the server's own message quoting a bound value (`Duplicate entry '…' for key
|
|
31
|
+
* 'PRIMARY'`) — is VendorTextNeverOnALine.test.ts: no line carries the server's message, and a
|
|
32
|
+
* caller's line about the error it caught carries the driver's sentence in its place.
|
|
36
33
|
*
|
|
37
34
|
* No server is needed: the REAL query layer and dialect run over a stub connection that rejects
|
|
38
35
|
* the way the client library does (its own `format` builds the interpolated `sql`), so the
|
|
@@ -140,11 +137,10 @@ describe('The driver never prints a bound value; what it throws is the vendor`s
|
|
|
140
137
|
|
|
141
138
|
const failures = captured.filter((log) => log.logLevel === 'error' && log.message === 'Failed when executing sql');
|
|
142
139
|
expect(failures).toHaveLength(1);
|
|
143
|
-
// (`ECHOED_KEY` is NOT asserted: the server quotes the colliding key in its own message — the
|
|
144
|
-
// vendor-echo class this suite leaves out by name; see the header.)
|
|
145
140
|
for (const log of captured) {
|
|
146
141
|
expect(lineOf(log)).not.toContain(SECRET_TOKEN);
|
|
147
142
|
expect(lineOf(log)).not.toContain(SECRET_EMAIL);
|
|
143
|
+
expect(lineOf(log)).not.toContain(ECHOED_KEY);
|
|
148
144
|
}
|
|
149
145
|
const [failure] = failures;
|
|
150
146
|
expect(failure.obj.sql).toBe(INSERT);
|
|
@@ -160,7 +156,7 @@ describe('The driver never prints a bound value; what it throws is the vendor`s
|
|
|
160
156
|
code: 'ER_DUP_ENTRY',
|
|
161
157
|
errno: 1062,
|
|
162
158
|
sqlState: '23000',
|
|
163
|
-
|
|
159
|
+
message: 'a row with that key already exists',
|
|
164
160
|
});
|
|
165
161
|
expect(Object.keys(failure.obj)).not.toContain('paramValues');
|
|
166
162
|
// The vendor error itself — the carrier of the interpolated SQL — is not on the line.
|
|
@@ -188,11 +184,10 @@ describe('The driver never prints a bound value; what it throws is the vendor`s
|
|
|
188
184
|
const failures = captured.filter((log) => log.logLevel === 'error');
|
|
189
185
|
expect(failures).toHaveLength(1);
|
|
190
186
|
expect(failures[0].obj.params).toEqual([{ type: 'string', length: SECRET_TOKEN.length }]);
|
|
191
|
-
// This statement's first binding IS the token, so the stub server echoes it
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
expect(
|
|
195
|
-
expect(lineOf({ ...failures[0], obj: { ...failures[0].obj, cause: ownFacts } })).not.toContain(SECRET_TOKEN);
|
|
187
|
+
// This statement's first binding IS the token, so the stub server echoes it in its own
|
|
188
|
+
// message — which no line carries.
|
|
189
|
+
expect(Object.keys(failures[0].obj.cause)).not.toContain('sqlMessage');
|
|
190
|
+
expect(lineOf(failures[0])).not.toContain(SECRET_TOKEN);
|
|
196
191
|
});
|
|
197
192
|
|
|
198
193
|
test('a failed statement inside a transaction: the same line, and the transaction rejects with the vendor`s error itself', async () => {
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import knex from 'knex';
|
|
2
|
+
import { KnexDriver } from '@proteinjs/db-driver-knex';
|
|
3
|
+
import { Logger } from '@proteinjs/logger';
|
|
4
|
+
import { CapturedLog, lineOf } from './util/printedLine';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The vendor's text never reaches a LOG LINE — the driver's own line, and any line a caller
|
|
8
|
+
* writes about the error it caught — while what the driver THROWS is the vendor's error itself,
|
|
9
|
+
* untouched (StatementParamLogging.test.ts holds that law; here the premise of every scenario is
|
|
10
|
+
* that the caught error still carries the values).
|
|
11
|
+
*
|
|
12
|
+
* The vendor error carries a statement's values three ways: the query layer rewrites its
|
|
13
|
+
* `message` to the SQL with every binding INTERPOLATED, the client library carries the same text
|
|
14
|
+
* as `sql`, and the server's own message quotes the value it refused (`Duplicate entry '<key>'
|
|
15
|
+
* for key 'PRIMARY'`). The driver's line never carried the first two; it did carry the third, and
|
|
16
|
+
* a caller that logged what it caught — whole, as a line's `error` or inside its `obj` — printed
|
|
17
|
+
* all three.
|
|
18
|
+
*
|
|
19
|
+
* So a line carries the vendor's CODES and the driver's own sentence. No server is needed: the
|
|
20
|
+
* REAL query layer and dialect run over a stub connection that rejects the way the client library
|
|
21
|
+
* does; lines are captured at the log WRITER and judged as the text a writer would print.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Fixture values shaped like the row content that must never be logged (none is a real credential).
|
|
25
|
+
const SECRET_TOKEN = 'rst_5d41402abc4b2a76b9719d911017c592';
|
|
26
|
+
const SECRET_EMAIL = 'casey.rivers@mail.example';
|
|
27
|
+
const ECHOED_KEY = 'c0ffee00-0000-4000-8000-000000000001';
|
|
28
|
+
const VALUES = [SECRET_TOKEN, SECRET_EMAIL, ECHOED_KEY];
|
|
29
|
+
|
|
30
|
+
type DriverStatics = { KNEX?: unknown };
|
|
31
|
+
|
|
32
|
+
/** What the stub's COMMIT answers with; nothing, unless a test says otherwise. */
|
|
33
|
+
let commitRejection: (() => Error) | undefined;
|
|
34
|
+
/** Whether the stub leaves every statement unanswered (a stuck server) instead of rejecting it. */
|
|
35
|
+
let silentStatements = false;
|
|
36
|
+
|
|
37
|
+
/** A connection that rejects every statement the way the client library does on a duplicate key. */
|
|
38
|
+
const rejectingConnection = () => {
|
|
39
|
+
const mysql = require('mysql');
|
|
40
|
+
return {
|
|
41
|
+
state: 'authenticated',
|
|
42
|
+
on: () => undefined,
|
|
43
|
+
removeAllListeners: () => undefined,
|
|
44
|
+
connect: (done: (error?: Error) => void) => done(),
|
|
45
|
+
end: (done: (error?: Error) => void) => done(),
|
|
46
|
+
query: (options: { sql: string }, bindings: any[], done: (error: Error | null, rows?: unknown[]) => void) => {
|
|
47
|
+
if (/^\s*COMMIT/i.test(options.sql) && commitRejection) {
|
|
48
|
+
done(commitRejection());
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(options.sql)) {
|
|
52
|
+
done(null, []);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (/^\s*SELECT 1/i.test(options.sql)) {
|
|
56
|
+
done(null, [[{ 1: 1 }], []]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (/^\s*KILL QUERY/i.test(options.sql)) {
|
|
60
|
+
done(null, []);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (silentStatements) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const sqlMessage = `Duplicate entry '${bindings[0]}' for key 'PRIMARY'`;
|
|
67
|
+
done(
|
|
68
|
+
Object.assign(new Error(`ER_DUP_ENTRY: ${sqlMessage}`), {
|
|
69
|
+
code: 'ER_DUP_ENTRY',
|
|
70
|
+
errno: 1062,
|
|
71
|
+
sqlState: '23000',
|
|
72
|
+
sqlMessage,
|
|
73
|
+
sql: mysql.format(options.sql, bindings),
|
|
74
|
+
})
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const settle = <T>(promise: Promise<T>): Promise<any> =>
|
|
81
|
+
promise.then(() => 'resolved' as const).catch((error) => error);
|
|
82
|
+
|
|
83
|
+
describe('the vendor`s text never reaches a log line; the thrown error still carries it', () => {
|
|
84
|
+
const driver = new KnexDriver({ host: 'localhost', user: 'root', password: '', dbName: 'test' });
|
|
85
|
+
const internals = driver as unknown as { logger: Logger };
|
|
86
|
+
const statics = KnexDriver as unknown as DriverStatics;
|
|
87
|
+
let captured: CapturedLog[] = [];
|
|
88
|
+
let instance: ReturnType<typeof knex>;
|
|
89
|
+
let previousInstance: unknown;
|
|
90
|
+
const capturing = (name: string) =>
|
|
91
|
+
new Logger({
|
|
92
|
+
name,
|
|
93
|
+
logLevel: 'debug',
|
|
94
|
+
logWriter: { write: (log: CapturedLog) => captured.push(log) } as any,
|
|
95
|
+
});
|
|
96
|
+
const callerLogger = capturing('ACaller');
|
|
97
|
+
|
|
98
|
+
beforeAll(() => {
|
|
99
|
+
instance = knex({ client: 'mysql', connection: { host: 'localhost', user: 'root', password: '' } });
|
|
100
|
+
(instance as any).client.driver = { createConnection: rejectingConnection };
|
|
101
|
+
previousInstance = statics.KNEX;
|
|
102
|
+
statics.KNEX = instance;
|
|
103
|
+
internals.logger = capturing('KnexDriver');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
statics.KNEX = previousInstance;
|
|
108
|
+
await instance.destroy();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
captured = [];
|
|
113
|
+
commitRejection = undefined;
|
|
114
|
+
silentStatements = false;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const INSERT = 'INSERT INTO `credential` (`id`, `email`, `token`, `attempts`, `cleared`) VALUES (?, ?, ?, ?, ?)';
|
|
118
|
+
const insertStatement = () => ({ sql: INSERT, params: [ECHOED_KEY, SECRET_EMAIL, SECRET_TOKEN, 3, null] });
|
|
119
|
+
|
|
120
|
+
/** The caller logs what it caught; no line — the driver's or the caller's — carries a value. */
|
|
121
|
+
const assertNeverOnALine = (caught: any) => {
|
|
122
|
+
callerLogger.error({ message: 'A caller`s own line about what it caught', error: caught });
|
|
123
|
+
callerLogger.warn({ message: 'A caller`s own line about what it caught', obj: { caught, list: [caught] } });
|
|
124
|
+
|
|
125
|
+
expect(captured.filter((log) => log.message === 'A caller`s own line about what it caught')).toHaveLength(2);
|
|
126
|
+
for (const log of captured) {
|
|
127
|
+
for (const value of VALUES) {
|
|
128
|
+
expect(lineOf(log)).not.toContain(value);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
test('a failed statement: the caught error carries every value; the driver`s line and a caller`s lines carry the codes and the sentence', async () => {
|
|
134
|
+
const caught = await settle(driver.runDml(insertStatement));
|
|
135
|
+
|
|
136
|
+
// The premise: the vendor error, as thrown, carries the values all three ways.
|
|
137
|
+
expect(caught.message).toContain(SECRET_TOKEN);
|
|
138
|
+
expect(caught.sql).toContain(SECRET_EMAIL);
|
|
139
|
+
expect(caught.sqlMessage).toContain(ECHOED_KEY);
|
|
140
|
+
|
|
141
|
+
assertNeverOnALine(caught);
|
|
142
|
+
const [failure] = captured.filter((log) => log.message === 'Failed when executing sql');
|
|
143
|
+
expect(failure.obj.cause).toEqual({
|
|
144
|
+
name: 'Error',
|
|
145
|
+
code: 'ER_DUP_ENTRY',
|
|
146
|
+
errno: 1062,
|
|
147
|
+
sqlState: '23000',
|
|
148
|
+
message: 'a row with that key already exists',
|
|
149
|
+
});
|
|
150
|
+
const [callersLine] = captured.filter((log) => log.message === 'A caller`s own line about what it caught');
|
|
151
|
+
expect(callersLine.error.message).toBe(
|
|
152
|
+
'Failed when executing sql (ER_DUP_ENTRY, errno 1062): a row with that key already exists'
|
|
153
|
+
);
|
|
154
|
+
expect(callersLine.error).toMatchObject({ code: 'ER_DUP_ENTRY', errno: 1062, sqlState: '23000' });
|
|
155
|
+
expect(String(callersLine.error.stack).split('\n').length).toBeGreaterThan(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test.each([
|
|
159
|
+
['DEVELOPMENT set AND DB_LOG_PARAM_VALUES=1', { DEVELOPMENT: 'true', DB_LOG_PARAM_VALUES: '1' }, true],
|
|
160
|
+
['DEVELOPMENT unset, DB_LOG_PARAM_VALUES=1', { DB_LOG_PARAM_VALUES: '1' }, false],
|
|
161
|
+
['DEVELOPMENT set, DB_LOG_PARAM_VALUES unset', { DEVELOPMENT: 'true' }, false],
|
|
162
|
+
])(
|
|
163
|
+
'the vendor`s text rides a line only behind the dev-only values switch — %s',
|
|
164
|
+
async (_gate, env: { [name: string]: string }, open) => {
|
|
165
|
+
const before = { DEVELOPMENT: process.env.DEVELOPMENT, DB_LOG_PARAM_VALUES: process.env.DB_LOG_PARAM_VALUES };
|
|
166
|
+
delete process.env.DEVELOPMENT;
|
|
167
|
+
delete process.env.DB_LOG_PARAM_VALUES;
|
|
168
|
+
Object.assign(process.env, env);
|
|
169
|
+
try {
|
|
170
|
+
const caught = await settle(driver.runDml(insertStatement));
|
|
171
|
+
callerLogger.error({ message: 'A caller`s own line', error: caught });
|
|
172
|
+
|
|
173
|
+
const [callersLine] = captured.filter((log) => log.message === 'A caller`s own line');
|
|
174
|
+
const [failure] = captured.filter((log) => log.message === 'Failed when executing sql');
|
|
175
|
+
if (open) {
|
|
176
|
+
expect(callersLine.error).toBe(caught);
|
|
177
|
+
expect(failure.obj.cause.sqlMessage).toContain(ECHOED_KEY);
|
|
178
|
+
} else {
|
|
179
|
+
expect(callersLine.error).not.toBe(caught);
|
|
180
|
+
expect(Object.keys(failure.obj.cause)).not.toContain('sqlMessage');
|
|
181
|
+
for (const log of captured) {
|
|
182
|
+
expect(lineOf(log)).not.toContain(ECHOED_KEY);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} finally {
|
|
186
|
+
for (const [name, value] of Object.entries(before)) {
|
|
187
|
+
if (value === undefined) {
|
|
188
|
+
delete process.env[name];
|
|
189
|
+
} else {
|
|
190
|
+
process.env[name] = value;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
test('a statement past the driver`s deadline: the caught error carries the bound values; every line says the statement timed out, and carries none', async () => {
|
|
198
|
+
const timedOut = 'the statement or its wait for a connection timed out';
|
|
199
|
+
silentStatements = true;
|
|
200
|
+
const deadlined = new KnexDriver({
|
|
201
|
+
host: 'localhost',
|
|
202
|
+
user: 'root',
|
|
203
|
+
password: '',
|
|
204
|
+
dbName: 'test',
|
|
205
|
+
operationDeadlineMs: 300,
|
|
206
|
+
});
|
|
207
|
+
(deadlined as unknown as { logger: Logger }).logger = capturing('KnexDriver');
|
|
208
|
+
|
|
209
|
+
const caught = await settle(deadlined.runDml(insertStatement));
|
|
210
|
+
|
|
211
|
+
// The premise: the query layer's timeout error, as thrown, names the deadline and carries the bindings.
|
|
212
|
+
expect(caught.name).toBe('KnexTimeoutError');
|
|
213
|
+
expect(caught.message).toMatch(/Defined query timeout of 300ms exceeded/);
|
|
214
|
+
expect(caught.bindings).toContain(SECRET_TOKEN);
|
|
215
|
+
|
|
216
|
+
assertNeverOnALine(caught);
|
|
217
|
+
const [failure] = captured.filter((log) => log.message === 'Failed when executing sql');
|
|
218
|
+
expect(failure.obj.cause).toEqual({ name: 'KnexTimeoutError', message: timedOut });
|
|
219
|
+
const [callersLine] = captured.filter((log) => log.message === 'A caller`s own line about what it caught');
|
|
220
|
+
expect(callersLine.error.name).toBe('KnexTimeoutError');
|
|
221
|
+
expect(callersLine.error.message).toBe(`Failed when executing sql: ${timedOut}`);
|
|
222
|
+
}, 10_000);
|
|
223
|
+
|
|
224
|
+
test('the same inside a transaction', async () => {
|
|
225
|
+
const caught = await settle(driver.runTransaction((transaction) => driver.runDml(insertStatement, transaction)));
|
|
226
|
+
|
|
227
|
+
expect(caught.message).toContain(SECRET_TOKEN);
|
|
228
|
+
assertNeverOnALine(caught);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('a commit the server refuses leaves through the transaction`s door, marked there; a caller`s own error thrown in the body prints as it is', async () => {
|
|
232
|
+
commitRejection = () =>
|
|
233
|
+
Object.assign(new Error(`ER_DUP_ENTRY: Duplicate entry '${ECHOED_KEY}' for key 'email'`), {
|
|
234
|
+
code: 'ER_DUP_ENTRY',
|
|
235
|
+
errno: 1062,
|
|
236
|
+
sqlState: '23000',
|
|
237
|
+
sqlMessage: `Duplicate entry '${ECHOED_KEY}' for key 'email'`,
|
|
238
|
+
});
|
|
239
|
+
const refusedCommit = await settle(driver.runTransaction(async () => 'the body ran clean'));
|
|
240
|
+
|
|
241
|
+
expect(String(refusedCommit.message)).toContain(ECHOED_KEY);
|
|
242
|
+
assertNeverOnALine(refusedCommit);
|
|
243
|
+
|
|
244
|
+
captured = [];
|
|
245
|
+
commitRejection = undefined;
|
|
246
|
+
const callersOwn = new Error('The order is already closed');
|
|
247
|
+
const outcome = await settle(
|
|
248
|
+
driver.runTransaction(async () => {
|
|
249
|
+
throw callersOwn;
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
callerLogger.error({ message: 'A caller`s own line', error: outcome });
|
|
253
|
+
|
|
254
|
+
expect(outcome).toBe(callersOwn);
|
|
255
|
+
expect(captured[0].error).toBe(callersOwn);
|
|
256
|
+
});
|
|
257
|
+
});
|