@villedemontreal/utils-knex 7.0.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.
Files changed (66) hide show
  1. package/README.md +236 -0
  2. package/dist/src/config/configs.d.ts +19 -0
  3. package/dist/src/config/configs.d.ts.map +1 -0
  4. package/dist/src/config/configs.js +27 -0
  5. package/dist/src/config/configs.js.map +1 -0
  6. package/dist/src/config/constants.d.ts +21 -0
  7. package/dist/src/config/constants.d.ts.map +1 -0
  8. package/dist/src/config/constants.js +21 -0
  9. package/dist/src/config/constants.js.map +1 -0
  10. package/dist/src/config/init.d.ts +16 -0
  11. package/dist/src/config/init.d.ts.map +1 -0
  12. package/dist/src/config/init.js +33 -0
  13. package/dist/src/config/init.js.map +1 -0
  14. package/dist/src/databaseContext.d.ts +9 -0
  15. package/dist/src/databaseContext.d.ts.map +1 -0
  16. package/dist/src/databaseContext.js +3 -0
  17. package/dist/src/databaseContext.js.map +1 -0
  18. package/dist/src/index.d.ts +5 -0
  19. package/dist/src/index.d.ts.map +1 -0
  20. package/dist/src/index.js +26 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/knexUtils.d.ts +134 -0
  23. package/dist/src/knexUtils.d.ts.map +1 -0
  24. package/dist/src/knexUtils.js +349 -0
  25. package/dist/src/knexUtils.js.map +1 -0
  26. package/dist/src/knexUtils.test.d.ts +2 -0
  27. package/dist/src/knexUtils.test.d.ts.map +1 -0
  28. package/dist/src/knexUtils.test.js +897 -0
  29. package/dist/src/knexUtils.test.js.map +1 -0
  30. package/dist/src/transactionManager.d.ts +34 -0
  31. package/dist/src/transactionManager.d.ts.map +1 -0
  32. package/dist/src/transactionManager.js +75 -0
  33. package/dist/src/transactionManager.js.map +1 -0
  34. package/dist/src/transactionManager.test.d.ts +10 -0
  35. package/dist/src/transactionManager.test.d.ts.map +1 -0
  36. package/dist/src/transactionManager.test.js +255 -0
  37. package/dist/src/transactionManager.test.js.map +1 -0
  38. package/dist/src/utils/logger.d.ts +12 -0
  39. package/dist/src/utils/logger.d.ts.map +1 -0
  40. package/dist/src/utils/logger.js +53 -0
  41. package/dist/src/utils/logger.js.map +1 -0
  42. package/dist/src/utils/testingConfigurations.d.ts +9 -0
  43. package/dist/src/utils/testingConfigurations.d.ts.map +1 -0
  44. package/dist/src/utils/testingConfigurations.js +16 -0
  45. package/dist/src/utils/testingConfigurations.js.map +1 -0
  46. package/dist/testing/testClient.d.ts +15 -0
  47. package/dist/testing/testClient.d.ts.map +1 -0
  48. package/dist/testing/testClient.js +55 -0
  49. package/dist/testing/testClient.js.map +1 -0
  50. package/dist/testing/testRepo.d.ts +8 -0
  51. package/dist/testing/testRepo.d.ts.map +1 -0
  52. package/dist/testing/testRepo.js +31 -0
  53. package/dist/testing/testRepo.js.map +1 -0
  54. package/dist/tsconfig.tsbuildinfo +1 -0
  55. package/package.json +76 -0
  56. package/src/config/configs.ts +34 -0
  57. package/src/config/constants.ts +33 -0
  58. package/src/config/init.ts +33 -0
  59. package/src/databaseContext.ts +9 -0
  60. package/src/index.ts +9 -0
  61. package/src/knexUtils.test.ts +1526 -0
  62. package/src/knexUtils.ts +459 -0
  63. package/src/transactionManager.test.ts +302 -0
  64. package/src/transactionManager.ts +94 -0
  65. package/src/utils/logger.ts +60 -0
  66. package/src/utils/testingConfigurations.ts +13 -0
@@ -0,0 +1,459 @@
1
+ // ==========================================
2
+ // Knex utilities
3
+ //
4
+ // Ok in test files
5
+ // tslint:disable:no-string-literal
6
+ // ==========================================
7
+ import { IPaginatedResult, utils } from '@villedemontreal/general-utils';
8
+ import { Promise as BBPromise } from 'bluebird';
9
+ import knex, { Knex } from 'knex';
10
+ import * as _ from 'lodash';
11
+ import * as sinon from 'sinon';
12
+ import { v4 as uuid } from 'uuid';
13
+ import { createLogger } from './utils/logger';
14
+
15
+ const logger = createLogger('knexUtils');
16
+
17
+ /**
18
+ * Knex utilities
19
+ */
20
+ export class KnexUtils {
21
+ protected static KNEX_TOTAL_COUNT_QUERY_COLUMN_NAME = 'count';
22
+ protected static TOTAL_COUNT_BUILDER_OPTION_NAME = 'isTotalCountBuilder';
23
+
24
+ /**
25
+ * Takes a Knex.QueryBuilder, which is the object created
26
+ * when defining a Knex query, an returns the rows total count.
27
+ *
28
+ * This function is useful when you already have a SELECT Knex query,
29
+ * but you only need the rows total count instead of the rows themselves!
30
+ *
31
+ * Warning!! This function only works with SELECT queries, and
32
+ * may fail in some untested complex situations... It only has
33
+ * been tested with simple select/from/where/orderBy queries.
34
+ *
35
+ * Example :
36
+ *
37
+ * const queryBuilder = client
38
+ * .from(BOOKS_TABLE_NAME)
39
+ * .orderBy("author");
40
+ *
41
+ * ... and then, instead of executing the query, by using
42
+ * "then()" or "await", you pass the builder to the
43
+ * "totalCount()" function :
44
+ *
45
+ * let totalCount: number = await knexUtils.totalCount(client, queryBuilder);
46
+ *
47
+ */
48
+ public async totalCount(knex: Knex, selectBuilder: Knex.QueryBuilder): Promise<number> {
49
+ const result = await this.paginateOrTotalCount(knex, selectBuilder, -1, -1, true);
50
+ return result.paging.totalCount;
51
+ }
52
+
53
+ /**
54
+ * Takes a Knex.QueryBuilder, which is the object created
55
+ * when defining a Knex query, a limit and a current
56
+ * page, then return a IPaginatedResult.
57
+ *
58
+ * In other words, instead of executing the query you
59
+ * are building with Knex directly, you pass the unsent
60
+ * builder to this function and you get :
61
+ * - Pagination for your query
62
+ * - The total number of elements your query would return if it
63
+ * wasn't paginated.
64
+ *
65
+ * Warning!! This function only works with SELECT queries, and
66
+ * may fail in some untested complex situations... It only has
67
+ * been tested with simple select/from/where/orderBy queries.
68
+ *
69
+ * If this function fails for one of your query, you'll have to
70
+ * duplicate the code of that query to make two separate queries :
71
+ * one for the rows only and one for the total count only.
72
+ *
73
+ * For example, to get 3 items starting at offset 9 :
74
+ *
75
+ * const paginatedResult = await knexUtils.paginate(
76
+ * client,
77
+ * client
78
+ * .select("id", "author", "title")
79
+ * .from(BOOKS_TABLE_NAME)
80
+ * .orderBy("author"),
81
+ * 9, 3);
82
+ */
83
+ public async paginate(
84
+ knex: Knex,
85
+ selectBuilder: Knex.QueryBuilder,
86
+ offset: number,
87
+ limit: number,
88
+ ): Promise<IPaginatedResult<any>> {
89
+ const result = await this.paginateOrTotalCount(knex, selectBuilder, offset, limit);
90
+ return result;
91
+ }
92
+
93
+ /**
94
+ * Creates a mocked Knex client, linked to a dummy database.
95
+ * The client allows you to define stubs that will simulate
96
+ * the result from the DB.
97
+ *
98
+ * Useful for testing!
99
+ */
100
+ public createKnexMockedClient = async (): Promise<IKnexMockedClient> => {
101
+ const knexMockedClient: IKnexMockedClient = knex({
102
+ client: 'sqlite',
103
+ connection: {
104
+ filename: './mydb.sqlite',
105
+ },
106
+ useNullAsDefault: true,
107
+ }) as any;
108
+
109
+ // ==========================================
110
+ // We add stubs that will allow to change the
111
+ // mocked result of a query.
112
+ // ==========================================
113
+ knexMockedClient.resultStub = sinon.stub();
114
+ knexMockedClient.resultStub.returns([]);
115
+
116
+ knexMockedClient.totalCountStub = sinon.stub();
117
+ knexMockedClient.totalCountStub.returns(0);
118
+
119
+ knexMockedClient.beforeQuerySpy = sinon.spy();
120
+
121
+ // ==========================================
122
+ // Returns a dummy connection object
123
+ // ==========================================
124
+ knexMockedClient.client.acquireConnection = () => {
125
+ // ==========================================
126
+ // We have to use a BlueBird Promise because
127
+ // this is what Knex is expecting and some functions
128
+ // specific to BlueBird promises are called.
129
+ // ==========================================
130
+ const promiseLike = new BBPromise((resolve: any, reject: any) => {
131
+ resolve({
132
+ // The "__knexUid" property is required by Knex
133
+ __knexUid: uuid(),
134
+ });
135
+ });
136
+ return promiseLike;
137
+ };
138
+
139
+ // ==========================================
140
+ // Called when the query is actually executed
141
+ // by Knex. We simply return the values from
142
+ // our stubs!
143
+ // ==========================================
144
+ knexMockedClient.client.query = (knexUuid: string, builder: any) => {
145
+ // The spy...
146
+ knexMockedClient.beforeQuerySpy(builder);
147
+
148
+ // ==========================================
149
+ // Query for the "totalCount" part of our "paginate()"
150
+ // function... We return the value from the
151
+ // *totalCount stub*.
152
+ // ==========================================
153
+ if (builder.options && builder.options[KnexUtils.TOTAL_COUNT_BUILDER_OPTION_NAME]) {
154
+ const theResult = knexMockedClient.totalCountStub();
155
+ const resultRow = {};
156
+ (resultRow as any)[KnexUtils.KNEX_TOTAL_COUNT_QUERY_COLUMN_NAME] = theResult;
157
+ return Promise.resolve(resultRow);
158
+ }
159
+
160
+ // ==========================================
161
+ // Regular query
162
+ // ==========================================
163
+
164
+ // The stub....
165
+ const result = knexMockedClient.resultStub();
166
+ return Promise.resolve(result);
167
+ };
168
+
169
+ // ==========================================
170
+ // Called by Knex to transform the result returned
171
+ // by the DB. We return our mocked result as is...
172
+ // ==========================================
173
+ knexMockedClient.client.processResponse = (obj: any, runner: any) => {
174
+ return obj;
175
+ };
176
+
177
+ // ==========================================
178
+ // When using a transaction, a new "client"
179
+ // is created. We replace it by our mocked client.
180
+ // ==========================================
181
+ (knexMockedClient as any)['context'].transaction = (transactionScope?: null, config?: any) => {
182
+ return transactionScope !== null ? (transactionScope as any)(knexMockedClient) : undefined;
183
+ };
184
+
185
+ return knexMockedClient;
186
+ };
187
+
188
+ /**
189
+ * For Oracle.
190
+ * Wraps a column name (or a "?") with LOWER or with a CONVERT function
191
+ * which will strip accents.
192
+ */
193
+ public wrapWithOracleModificationkeywords(
194
+ columnNameOrInterrogationMark: string,
195
+ isConvert: boolean,
196
+ isLower: boolean,
197
+ ): string {
198
+ if (isConvert && isLower) {
199
+ return `LOWER(CONVERT(${columnNameOrInterrogationMark}, 'US7ASCII', 'WE8ISO8859P1'))`;
200
+ }
201
+ if (isConvert) {
202
+ return `CONVERT(${columnNameOrInterrogationMark}, 'US7ASCII', 'WE8ISO8859P1')`;
203
+ }
204
+ if (isLower) {
205
+ return `LOWER(${columnNameOrInterrogationMark})`;
206
+ }
207
+ return columnNameOrInterrogationMark;
208
+ }
209
+
210
+ /**
211
+ * For Oracle.
212
+ * Adds a LIKE clause, where the values can be compared lowercased (isLower), by removing the
213
+ * accents first (isConvert), and where the "val" can starts or ends with a "*" wildcard.
214
+ */
215
+ public addOracleLikeClause(
216
+ queryBuilder: Knex.QueryBuilder,
217
+ columnName: string,
218
+ val: string,
219
+ isConvert: boolean,
220
+ isLower: boolean,
221
+ ): Knex.QueryBuilder {
222
+ let valClean = val;
223
+ let wildcardPrefix = '';
224
+ let wildcardSuffix = '';
225
+ if (val.startsWith('*')) {
226
+ wildcardPrefix = "'%' || ";
227
+ valClean = _.trimStart(valClean, '*');
228
+ }
229
+ if (valClean.endsWith('*')) {
230
+ wildcardSuffix = " || '%'";
231
+ valClean = _.trimEnd(valClean, '*');
232
+ }
233
+
234
+ let queryBuilderClean = queryBuilder;
235
+ if (wildcardPrefix === '' && wildcardSuffix === '' && !isConvert && !isLower) {
236
+ queryBuilderClean = queryBuilderClean.where(columnName, valClean);
237
+ } else {
238
+ const clause = `${this.wrapWithOracleModificationkeywords(
239
+ columnName,
240
+ isConvert,
241
+ isLower,
242
+ )} LIKE ${wildcardPrefix}${this.wrapWithOracleModificationkeywords(
243
+ '?',
244
+ isConvert,
245
+ isLower,
246
+ )}${wildcardSuffix}`;
247
+ queryBuilderClean = queryBuilderClean.whereRaw(clause, valClean);
248
+ }
249
+
250
+ return queryBuilderClean;
251
+ }
252
+
253
+ /**
254
+ * For SQL Server.
255
+ * Wraps a column name (or a "?") with LOWER and/or with a CAST function
256
+ * which will strip accents.
257
+ */
258
+ public wrapWithSqlServerModificationKeywords(
259
+ columnNameOrInterrogationMark: string,
260
+ isConvert: boolean,
261
+ isLower: boolean,
262
+ ): string {
263
+ if (!isLower && !isConvert) {
264
+ return columnNameOrInterrogationMark;
265
+ }
266
+
267
+ if (isLower && !isConvert) {
268
+ return `LOWER(${columnNameOrInterrogationMark})`;
269
+ }
270
+
271
+ // ==========================================
272
+ // There is no magic method in SQL Server to strip accents.
273
+ // This is the best I found : https://stackoverflow.com/a/3578644/843699
274
+ // ... I added "œ" and "æ" management too, which don't work with the
275
+ // Stack Overflow trick.
276
+ // ==========================================
277
+ const cast =
278
+ `CAST(` +
279
+ `REPLACE(REPLACE(REPLACE(REPLACE(${columnNameOrInterrogationMark}, 'œ', 'oe'), 'Œ', 'OE'), 'æ', 'ae'), 'Æ', 'AE')` +
280
+ `AS VARCHAR(max)) COLLATE SQL_Latin1_General_Cp1251_CS_AS`;
281
+
282
+ if (!isLower) {
283
+ return cast;
284
+ }
285
+ return `LOWER(${cast})`;
286
+ }
287
+
288
+ /**
289
+ * For SQL Server.
290
+ * Adds a LIKE clause, where the values can be compared lowercased (lower), by removing the
291
+ * accents first (removeAccent), and where the "val" can starts or ends with a "*" wildcard
292
+ * (acceptWildcard).
293
+ */
294
+ public addSqlServerLikeClause(
295
+ queryBuilder: Knex.QueryBuilder,
296
+ columnName: string,
297
+ val: string,
298
+ acceptWildcard: boolean,
299
+ removeAccents: boolean,
300
+ lower: boolean,
301
+ ): Knex.QueryBuilder {
302
+ let valClean = val;
303
+ let wildcardPrefix = '';
304
+ let wildcardSuffix = '';
305
+
306
+ if (acceptWildcard) {
307
+ if (val.startsWith('*')) {
308
+ wildcardPrefix = "'%' + ";
309
+ valClean = _.trimStart(valClean, '*');
310
+ }
311
+ if (valClean.endsWith('*')) {
312
+ wildcardSuffix = " + '%'";
313
+ valClean = _.trimEnd(valClean, '*');
314
+ }
315
+ }
316
+
317
+ let queryBuilderClean = queryBuilder;
318
+ if (wildcardPrefix === '' && wildcardSuffix === '' && !removeAccents && !lower) {
319
+ queryBuilderClean = queryBuilderClean.where(columnName, valClean);
320
+ } else {
321
+ const clause = `${this.wrapWithSqlServerModificationKeywords(
322
+ columnName,
323
+ removeAccents,
324
+ lower,
325
+ )} LIKE ${wildcardPrefix}${this.wrapWithSqlServerModificationKeywords(
326
+ '?',
327
+ removeAccents,
328
+ lower,
329
+ )}${wildcardSuffix}`;
330
+ queryBuilderClean = queryBuilderClean.whereRaw(clause, valClean);
331
+ }
332
+
333
+ return queryBuilderClean;
334
+ }
335
+
336
+ /**
337
+ * @param totalCountOnly if true, only the request to get the total count will
338
+ * be made and an empty array will be returned as the rows.
339
+ */
340
+ protected async paginateOrTotalCount(
341
+ knex: Knex,
342
+ selectBuilder: Knex.QueryBuilder,
343
+ offset: number,
344
+ limit: number,
345
+ totalCountOnly = false,
346
+ ): Promise<IPaginatedResult<any>> {
347
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
348
+ if (!selectBuilder) {
349
+ throw new Error('A Knex SELECT query builder is required.');
350
+ }
351
+
352
+ if ((selectBuilder as any)['_method'] !== 'select') {
353
+ throw new Error(
354
+ "The 'paginate()' and 'totalCount()' functions are only available on a SELECT query builder!",
355
+ );
356
+ }
357
+
358
+ let offsetClean = offset;
359
+ if (!utils.isIntegerValue(offsetClean, true, true)) {
360
+ logger.debug(`Invalid offset "${offsetClean}", 0 will be used instead`);
361
+ offsetClean = 0;
362
+ }
363
+ offsetClean = Number(offsetClean);
364
+ let limitClean = limit;
365
+ if (!utils.isIntegerValue(limitClean, true, false)) {
366
+ logger.debug(`Invalid limit "${limitClean}", 1 will be used instead`);
367
+ limitClean = 1;
368
+ }
369
+ limitClean = Number(limitClean);
370
+
371
+ // ==========================================
372
+ // We wrap the original select query in a
373
+ // "count()" query. We also add an option to this
374
+ // new query so we can later know this is the "totalCount"
375
+ // generated query.
376
+ // ==========================================
377
+ let countBuilder = knex.count(`* AS ${KnexUtils.KNEX_TOTAL_COUNT_QUERY_COLUMN_NAME}`);
378
+ const options: any = {};
379
+ options[KnexUtils.TOTAL_COUNT_BUILDER_OPTION_NAME] = true;
380
+ countBuilder = countBuilder.options(options);
381
+
382
+ // ==========================================
383
+ // For the subquery, we clone the
384
+ // original query but remove the "order by"
385
+ // clause, the limit and the offset.
386
+ // ==========================================
387
+ const countBuilderSubSelect: Knex.QueryBuilder = selectBuilder.clone();
388
+ delete (countBuilderSubSelect as any)['_single'].offset;
389
+ delete (countBuilderSubSelect as any)['_single'].limit;
390
+ if ((countBuilderSubSelect as any)['_statements']) {
391
+ const totalCountStatements = [];
392
+ for (const statement of (countBuilderSubSelect as any)['_statements']) {
393
+ if (statement.grouping !== 'order') {
394
+ totalCountStatements.push(statement);
395
+ }
396
+ }
397
+ (countBuilderSubSelect as any)['_statements'] = totalCountStatements;
398
+ }
399
+ countBuilder = countBuilder.from(countBuilderSubSelect.as('_knexSub') as any);
400
+
401
+ // ==========================================
402
+ // Are we simply interested in the total count,
403
+ // or the actual rows too?
404
+ // We do not use something like Promise.all() to
405
+ // run both queries because of :
406
+ // https://github.com/tediousjs/node-mssql/issues/491
407
+ // ==========================================
408
+ const rs = await countBuilder.first();
409
+ const totalCount = Number(rs[KnexUtils.KNEX_TOTAL_COUNT_QUERY_COLUMN_NAME]);
410
+
411
+ let rows: any[];
412
+ if (!totalCountOnly) {
413
+ rows = await selectBuilder.offset(offsetClean).limit(limitClean);
414
+ }
415
+
416
+ const result: IPaginatedResult<any> = {
417
+ items: rows,
418
+ paging: {
419
+ totalCount,
420
+ limit: limitClean,
421
+ offset: offsetClean,
422
+ },
423
+ };
424
+
425
+ return result;
426
+ }
427
+ }
428
+ export const knexUtils: KnexUtils = new KnexUtils();
429
+
430
+ /**
431
+ * A Mocked Knex client.
432
+ */
433
+ export interface IKnexMockedClient extends Knex {
434
+ /**
435
+ * The stub that is going to return the result
436
+ * when the query is executed.
437
+ *
438
+ * Defaults to an empty array.
439
+ */
440
+ resultStub: sinon.SinonStub;
441
+
442
+ /**
443
+ * If the query is paginated, this stub
444
+ * will return the "totalCount" part of
445
+ * the result.
446
+ *
447
+ * Defaults to 0.
448
+ */
449
+ totalCountStub: sinon.SinonStub;
450
+
451
+ /**
452
+ * This spy is going to be called just before
453
+ * Knex actually execute the query. The builder
454
+ * has at this point been converted to a
455
+ * SQL string and you have access to this SQL and
456
+ * other informations.
457
+ */
458
+ beforeQuerySpy: sinon.SinonSpy;
459
+ }