sql-love 0.9.6 → 1.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.
- package/CHANGELOG.md +12 -0
- package/LICENSE.md +1 -1
- package/README.md +151 -28
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/src/extractLookup/extractLookup.d.ts +9 -0
- package/dist/src/extractLookup/extractLookup.js +21 -0
- package/dist/src/getPagination/getPagination.js +21 -6
- package/dist/src/runMysql/runMysql.d.ts +12 -0
- package/dist/src/runMysql/runMysql.js +12 -0
- package/dist/src/runPrisma/runPrisma.d.ts +15 -0
- package/dist/src/runPrisma/runPrisma.js +17 -1
- package/dist/src/substrCount/substrCount.d.ts +5 -0
- package/dist/src/substrCount/substrCount.js +5 -0
- package/dist/src/toSafeJson/toSafeJson.d.ts +25 -0
- package/dist/src/toSafeJson/toSafeJson.js +48 -0
- package/index.ts +13 -0
- package/package.json +5 -5
- package/src/extractLookup/extractLookup.spec.ts +38 -0
- package/src/extractLookup/extractLookup.ts +33 -0
- package/src/getPagination/getPagination.spec.ts +19 -5
- package/src/getPagination/getPagination.ts +18 -6
- package/src/runMysql/runMysql.spec.ts +129 -7
- package/src/runMysql/runMysql.ts +12 -0
- package/src/runPrisma/runPrisma.spec.ts +32 -0
- package/src/runPrisma/runPrisma.ts +43 -7
- package/src/substrCount/substrCount.ts +12 -7
- package/src/toSafeJson/toSafeJson.spec.ts +28 -0
- package/src/toSafeJson/toSafeJson.ts +56 -0
- package/vitest.config.ts +11 -0
- package/vitest.config.js +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## Change Log
|
|
2
2
|
|
|
3
|
+
### v1.0.0 - 12 Dec 2023
|
|
4
|
+
|
|
5
|
+
- Upgrade to vitest v1
|
|
6
|
+
- More documentation
|
|
7
|
+
- 100% test coverage
|
|
8
|
+
- First stable release
|
|
9
|
+
|
|
10
|
+
### v0.9.7 - 7 Dec 2023
|
|
11
|
+
|
|
12
|
+
- Added extractLookup utility
|
|
13
|
+
- Added toSafeJson utilities
|
|
14
|
+
|
|
3
15
|
### v0.9.6 - 5 Dec 2023
|
|
4
16
|
|
|
5
17
|
- Utility functions (extractGrouped, extractIndexed, getPagination, runPrisma, runMysql)
|
package/LICENSE.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
ISC License (ISC)
|
|
2
|
-
Copyright
|
|
2
|
+
Copyright 2023 Ken Snyder
|
|
3
3
|
|
|
4
4
|
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
5
5
|
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
<img alt="SQL Love Logo" src="https://github.com/kensnyder/sql-love/raw/main/assets/sql-love-logo.png?v=0.
|
|
1
|
+
<img alt="SQL Love Logo" src="https://github.com/kensnyder/sql-love/raw/main/assets/sql-love-logo.png?v=1.0.1" width="250" height="208" />
|
|
2
2
|
|
|
3
3
|
# sql-love
|
|
4
4
|
|
|
5
|
-
[](https://npmjs.com/package/sql-love)
|
|
6
|
+
[](https://www.npmjs.com/package/sql-love?activeTab=dependencies)
|
|
7
|
+
[](https://github.com/kensnyder/sql-love/actions)
|
|
8
|
+
[](https://codecov.io/gh/kensnyder/sql-love)
|
|
9
|
+
[](https://opensource.org/licenses/ISC)
|
|
10
10
|
|
|
11
11
|
Classes for parsing and building SQL select queries in Node
|
|
12
12
|
|
|
@@ -22,17 +22,18 @@ npm install sql-love
|
|
|
22
22
|
- [Parsing base SQL](#parsing-base-sql)
|
|
23
23
|
- [SQL injection](#sql-injection)
|
|
24
24
|
- [Building the Query](#building-the-query)
|
|
25
|
-
- [More examples](#
|
|
25
|
+
- [More examples](#more-examples)
|
|
26
26
|
- [Counting results](#counting-results)
|
|
27
27
|
- [Other methods](#other-methods)
|
|
28
|
-
- [
|
|
28
|
+
- [Parser limitations](#parser-limitations)
|
|
29
29
|
- [Utility functions](#utility-functions)
|
|
30
30
|
- [getPagination](#getpagination)
|
|
31
|
-
- [extractGrouped](#
|
|
32
|
-
- [extractIndexed](#
|
|
33
|
-
- [
|
|
34
|
-
- [
|
|
35
|
-
- [
|
|
31
|
+
- [extractGrouped](#extractgrouped)
|
|
32
|
+
- [extractIndexed](#extractindexed)
|
|
33
|
+
- [extractLookup](#extractlookup)
|
|
34
|
+
- [runPrisma and runPrismaWithCount](#runprisma-and-runprismawithcount)
|
|
35
|
+
- [runMysql and runMysqlWithCount](#runmysql-and-runmysqlwithcount)
|
|
36
|
+
- [toSafeJson functions](#tosafejson)
|
|
36
37
|
- [How to contribute](./CONTRIBUTING.md)
|
|
37
38
|
- [ISC license](./LICENSE.md)
|
|
38
39
|
|
|
@@ -64,17 +65,29 @@ if (areaCode) {
|
|
|
64
65
|
query.sort(sortField);
|
|
65
66
|
query.limit(limitTo);
|
|
66
67
|
const { sql, bindings } = query.compile();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Then execute the SQL in your preferred client:
|
|
67
71
|
|
|
68
|
-
|
|
69
|
-
//
|
|
72
|
+
```js
|
|
73
|
+
// mysql2:
|
|
70
74
|
connection.query(sql, bindings, (err, results, fields) => {});
|
|
71
|
-
|
|
75
|
+
|
|
76
|
+
// Prisma:
|
|
72
77
|
const result = await prisma.$queryRawUnsafe(sql, ...bindings);
|
|
73
78
|
|
|
74
|
-
//
|
|
75
|
-
|
|
79
|
+
// Cloudflare d1
|
|
80
|
+
const { results } = await env.DB.prepare(sql)
|
|
81
|
+
.bind(...bindings)
|
|
82
|
+
.all();
|
|
76
83
|
```
|
|
77
84
|
|
|
85
|
+
_Note that these are prepared statements so the values in the "bindings"_
|
|
86
|
+
_array are safe from SQL injection, with the caveat that you are in charge of_
|
|
87
|
+
_quoting any identifiers. For instance, we don't recommend populating `u.email`_
|
|
88
|
+
_from user input in the example above. But if you do, be sure to use your_
|
|
89
|
+
_client's `quoteIdentifier()` function._
|
|
90
|
+
|
|
78
91
|
It is possible to add placeholders to the base query.
|
|
79
92
|
|
|
80
93
|
```js
|
|
@@ -110,7 +123,7 @@ And "bindings" equals:
|
|
|
110
123
|
You may specify a compiler engine to use. The default is `"mysql"`.
|
|
111
124
|
|
|
112
125
|
```js
|
|
113
|
-
import { SelectBuilder } from 'sql-love';
|
|
126
|
+
import { SelectBuilder, setDefaultEngine } from 'sql-love';
|
|
114
127
|
|
|
115
128
|
const query = new SelectBuilder('SELECT * FROM users')
|
|
116
129
|
.limit(10)
|
|
@@ -118,19 +131,18 @@ const query = new SelectBuilder('SELECT * FROM users')
|
|
|
118
131
|
.where('id', 1);
|
|
119
132
|
|
|
120
133
|
query.compile({ engine: 'mysql' }).sql;
|
|
134
|
+
query.compile({ engine: 'sqlite' }).sql;
|
|
121
135
|
// SELECT * FROM users WHERE id = ? OFFSET 20 LIMIT 10
|
|
122
136
|
|
|
123
137
|
query.compile({ engine: 'pg' }).sql;
|
|
124
138
|
// SELECT * FROM users WHERE id = $1 OFFSET 20 LIMIT 10
|
|
125
139
|
|
|
126
140
|
query.compile({ engine: 'mssql' }).sql;
|
|
127
|
-
// SELECT * FROM users WHERE id = ? OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
|
|
128
|
-
|
|
129
141
|
query.compile({ engine: 'oracle' }).sql;
|
|
130
142
|
// SELECT * FROM users WHERE id = ? OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
|
|
131
143
|
|
|
132
144
|
// Or you can specify the default engine to use in the compile function
|
|
133
|
-
|
|
145
|
+
setDefaultEngine('pg');
|
|
134
146
|
```
|
|
135
147
|
|
|
136
148
|
### SQL injection
|
|
@@ -138,10 +150,19 @@ SelectBuilder.setDefaultEngine('pg');
|
|
|
138
150
|
You should use prepared statements to run the sql and bindings returned by
|
|
139
151
|
`SelectBuilder.compile()`. This will protect you from SQL injection.
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
153
|
+
Using `mysqlClient.query(sql, bindings)` and
|
|
154
|
+
`prisma.$queryRawUnsafe(sql, ...bindings)` are examples of using prepared
|
|
143
155
|
statements.
|
|
144
156
|
|
|
157
|
+
Note that even though the name `prisma.$queryRawUnsafe(sql, ...bindings)`
|
|
158
|
+
implies that the query is unsafe, it is actually safe because
|
|
159
|
+
`prisma.$queryRawUnsafe` uses prepared statements.
|
|
160
|
+
|
|
161
|
+
SQL Love also includes utility functions for running queries in mysql and
|
|
162
|
+
Prisma so you don't have to remember these functions. See documentation for
|
|
163
|
+
[runPrisma](#runprisma-and-runprismawithcount) and
|
|
164
|
+
[runMysql](#runmysql-and-runmysqlwithcount) below.
|
|
165
|
+
|
|
145
166
|
### Building the query
|
|
146
167
|
|
|
147
168
|
Use the following methods to build queries. And see
|
|
@@ -221,7 +242,17 @@ query.where({ status: ['pending', 'approved'] });
|
|
|
221
242
|
query.where({ 'status NOT IN': ['pending', 'approved'] });
|
|
222
243
|
query.where({ 'status !=': ['pending', 'approved'] });
|
|
223
244
|
query.where({ 'status <>': ['pending', 'approved'] });
|
|
224
|
-
|
|
245
|
+
|
|
246
|
+
// the following demonstrates how to use question marks for binding
|
|
247
|
+
query.where(
|
|
248
|
+
'users.id IN (SELECT user_id FROM roles WHERE customer_id IN (?, ?))',
|
|
249
|
+
[1, 2]
|
|
250
|
+
);
|
|
251
|
+
query.where(
|
|
252
|
+
'users.id IN (SELECT user_id FROM roles WHERE customer_id = ?)',
|
|
253
|
+
// even if there is only one placeholder, you must use an array
|
|
254
|
+
[1]
|
|
255
|
+
);
|
|
225
256
|
|
|
226
257
|
// The following demonstrates using objects to specify multiple conditions
|
|
227
258
|
query.where({
|
|
@@ -233,6 +264,8 @@ query.where({
|
|
|
233
264
|
conditions: ['new', 'used'],
|
|
234
265
|
});
|
|
235
266
|
|
|
267
|
+
// Note: .having() supports the same signatures as .where()
|
|
268
|
+
|
|
236
269
|
// The following demonstrate equivalent ways to use OR
|
|
237
270
|
query.orWhere([{ approved_at: null }, { denied_at: null }]);
|
|
238
271
|
query.orWhere({ approved_at: null, denied_at: null });
|
|
@@ -242,6 +275,7 @@ query.orWhere([
|
|
|
242
275
|
['denied_at =', null],
|
|
243
276
|
]);
|
|
244
277
|
query.where('(approved_at IS NULL OR denied_at IS NULL)');
|
|
278
|
+
|
|
245
279
|
// Note: .orHaving() supports the same signatures as .orWhere()
|
|
246
280
|
|
|
247
281
|
// The following demonstrate joins
|
|
@@ -451,12 +485,31 @@ const grouped = extractGrouped('dept', records);
|
|
|
451
485
|
*/
|
|
452
486
|
```
|
|
453
487
|
|
|
488
|
+
### extractLookup
|
|
489
|
+
|
|
490
|
+
You can create a lookup object based on a results set
|
|
491
|
+
|
|
492
|
+
```js
|
|
493
|
+
const records = [
|
|
494
|
+
{ id: 1, name: 'John', age: 30 },
|
|
495
|
+
{ id: 2, name: 'Jane', age: 35 },
|
|
496
|
+
];
|
|
497
|
+
|
|
498
|
+
const nameById = extractLookup('id', 'name', records);
|
|
499
|
+
/*
|
|
500
|
+
{
|
|
501
|
+
'1': 'John',
|
|
502
|
+
'2': 'Jane',
|
|
503
|
+
}
|
|
504
|
+
*/
|
|
505
|
+
```
|
|
506
|
+
|
|
454
507
|
### runPrisma and runPrismaWithCount
|
|
455
508
|
|
|
456
509
|
`runPrisma()` will run a query using Prisma and return the results.
|
|
457
510
|
|
|
458
511
|
```js
|
|
459
|
-
import { SelectBuilder, runPrisma
|
|
512
|
+
import { SelectBuilder, runPrisma } from 'sql-love';
|
|
460
513
|
import { prisma } from '~/db.server';
|
|
461
514
|
|
|
462
515
|
const query = new SelectBuilder('SELECT * FROM users');
|
|
@@ -469,7 +522,7 @@ const rows = runPrisma(prisma, query, { engine: 'pg' });
|
|
|
469
522
|
found rows and pagination.
|
|
470
523
|
|
|
471
524
|
```js
|
|
472
|
-
import { SelectBuilder,
|
|
525
|
+
import { SelectBuilder, runPrismaWithCount } from 'sql-love';
|
|
473
526
|
import { prisma } from '~/db.server';
|
|
474
527
|
|
|
475
528
|
const query = new SelectBuilder('SELECT * FROM users')
|
|
@@ -485,4 +538,74 @@ See [getPagination](#getpagination) for details on the pagination object.
|
|
|
485
538
|
|
|
486
539
|
### runMysql and runMysqlWithCount
|
|
487
540
|
|
|
488
|
-
|
|
541
|
+
`runMysql()` will run a query using mysql and return the results.
|
|
542
|
+
|
|
543
|
+
```js
|
|
544
|
+
import { SelectBuilder, runMysql } from 'sql-love';
|
|
545
|
+
import { mysql } from 'mysql2';
|
|
546
|
+
const client = mysql.createConnection(config);
|
|
547
|
+
|
|
548
|
+
const query = new SelectBuilder('SELECT * FROM users');
|
|
549
|
+
query.where('dept', 'Marketing');
|
|
550
|
+
|
|
551
|
+
const rows = await runMysql(client, query);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
`runMysqlWithCount()` will run a query using mysql and return the results,
|
|
555
|
+
found rows and pagination.
|
|
556
|
+
|
|
557
|
+
```js
|
|
558
|
+
import { SelectBuilder, runMysqlWithCount } from 'sql-love';
|
|
559
|
+
import { mysql } from 'mysql2';
|
|
560
|
+
const client = mysql.createConnection(config);
|
|
561
|
+
|
|
562
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
563
|
+
.where('dept', 'Marketing')
|
|
564
|
+
.limit(2)
|
|
565
|
+
.page(5);
|
|
566
|
+
|
|
567
|
+
const { records, total, pagination } = await runMysqlWithCount(client, query);
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
You can also use the mysql2 promise api:
|
|
571
|
+
|
|
572
|
+
```js
|
|
573
|
+
import { SelectBuilder, runMysqlAsync, runMysqlAsyncWithCount } from 'sql-love';
|
|
574
|
+
import { mysql } from 'mysql2/promise';
|
|
575
|
+
const client = await mysql.createConnection(config);
|
|
576
|
+
|
|
577
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
578
|
+
.where('dept', 'Marketing')
|
|
579
|
+
.limit(2)
|
|
580
|
+
.page(5);
|
|
581
|
+
|
|
582
|
+
const records = await runMysqlAsync(client, query);
|
|
583
|
+
// OR
|
|
584
|
+
const { records, total, pagination } = await runMysqlAsyncWithCount(
|
|
585
|
+
client,
|
|
586
|
+
query
|
|
587
|
+
);
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### toSafeJson
|
|
591
|
+
|
|
592
|
+
With some database clients such as Prisma, your recordsets may contain BigInt
|
|
593
|
+
objects. These are not safe to pass to JSON.stringify(). The following are
|
|
594
|
+
examples of the toSafeJson\* utility functions.
|
|
595
|
+
|
|
596
|
+
```js
|
|
597
|
+
import { toSafeJsonString, toSafeJsonRecords } from 'sql-love';
|
|
598
|
+
|
|
599
|
+
const records = [{ count: 12n }, { count: 9007199254740993n }];
|
|
600
|
+
|
|
601
|
+
toSafeJsonRecords(records);
|
|
602
|
+
// [
|
|
603
|
+
// { count: 12 },
|
|
604
|
+
// { count: "9007199254740993" },
|
|
605
|
+
// ]
|
|
606
|
+
|
|
607
|
+
toSafeJsonString(records);
|
|
608
|
+
// '[{"count":12},{"count":"9007199254740993"}]'
|
|
609
|
+
|
|
610
|
+
// Note that BigInt values too big for Number are converted to strings
|
|
611
|
+
```
|
package/dist/index.d.ts
CHANGED
|
@@ -2,5 +2,8 @@ export { default as Parser } from './src/Parser/Parser';
|
|
|
2
2
|
export { default as SelectBuilder } from './src/SelectBuilder/SelectBuilder';
|
|
3
3
|
export { default as extractGrouped } from './src/extractGrouped/extractGrouped';
|
|
4
4
|
export { default as extractIndexed } from './src/extractIndexed/extractIndexed';
|
|
5
|
+
export { default as extractLookup } from './src/extractLookup/extractLookup';
|
|
5
6
|
export { default as getPagination } from './src/getPagination/getPagination';
|
|
7
|
+
export { runMysql, runMysqlAsync, runMysqlAsyncWithCount, runMysqlWithCount, } from './src/runMysql/runMysql';
|
|
6
8
|
export { runPrisma, runPrismaWithCount } from './src/runPrisma/runPrisma';
|
|
9
|
+
export { toSafeJsonRecord, toSafeJsonRecords, toSafeJsonString, toSafeJsonValue, } from './src/toSafeJson/toSafeJson';
|
package/dist/index.js
CHANGED
|
@@ -2,5 +2,8 @@ export { default as Parser } from './src/Parser/Parser';
|
|
|
2
2
|
export { default as SelectBuilder } from './src/SelectBuilder/SelectBuilder';
|
|
3
3
|
export { default as extractGrouped } from './src/extractGrouped/extractGrouped';
|
|
4
4
|
export { default as extractIndexed } from './src/extractIndexed/extractIndexed';
|
|
5
|
+
export { default as extractLookup } from './src/extractLookup/extractLookup';
|
|
5
6
|
export { default as getPagination } from './src/getPagination/getPagination';
|
|
7
|
+
export { runMysql, runMysqlAsync, runMysqlAsyncWithCount, runMysqlWithCount, } from './src/runMysql/runMysql';
|
|
6
8
|
export { runPrisma, runPrismaWithCount } from './src/runPrisma/runPrisma';
|
|
9
|
+
export { toSafeJsonRecord, toSafeJsonRecords, toSafeJsonString, toSafeJsonValue, } from './src/toSafeJson/toSafeJson';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given an array of records, extract an object with keyField-valueField pairs
|
|
3
|
+
* @param records An array of records
|
|
4
|
+
* @param keyField The field to use as the key
|
|
5
|
+
* @param valueField The field to use as the value
|
|
6
|
+
*/
|
|
7
|
+
export default function extractLookup<T extends Array<Record<K | V, any>>, K extends keyof T[number], V extends keyof T[number]>(keyField: K, valueField: V, records: T): {
|
|
8
|
+
[key in T[number][K]]: T[number][V];
|
|
9
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given an array of records, extract an object with keyField-valueField pairs
|
|
3
|
+
* @param records An array of records
|
|
4
|
+
* @param keyField The field to use as the key
|
|
5
|
+
* @param valueField The field to use as the value
|
|
6
|
+
*/
|
|
7
|
+
export default function extractLookup(keyField, valueField, records) {
|
|
8
|
+
const lookup = {};
|
|
9
|
+
let idx = 0;
|
|
10
|
+
for (const record of records) {
|
|
11
|
+
if (!(keyField in record)) {
|
|
12
|
+
throw new Error(`Column "${String(keyField)}" is not present in record ${idx}`);
|
|
13
|
+
}
|
|
14
|
+
if (!(valueField in record)) {
|
|
15
|
+
throw new Error(`Column "${String(valueField)}" is not present in record ${idx}`);
|
|
16
|
+
}
|
|
17
|
+
idx++;
|
|
18
|
+
lookup[String(record[keyField])] = record[valueField];
|
|
19
|
+
}
|
|
20
|
+
return lookup;
|
|
21
|
+
}
|
|
@@ -5,13 +5,28 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export default function getPagination(query, total) {
|
|
7
7
|
total = Number(total) || 0;
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
const perPage = Number(query._limit) || null;
|
|
9
|
+
let page = null;
|
|
10
|
+
if (total === 0) {
|
|
11
|
+
page = null;
|
|
12
|
+
}
|
|
13
|
+
else if (query._page) {
|
|
14
|
+
page = Number(query._page);
|
|
15
|
+
}
|
|
16
|
+
else if (query._limit && query._offset) {
|
|
17
|
+
page = Math.floor(Number(query._offset) / Number(query._limit)) + 1;
|
|
18
|
+
}
|
|
19
|
+
else if (query._limit && !query._offset) {
|
|
20
|
+
page = 1;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
page = null;
|
|
24
|
+
}
|
|
25
|
+
const numPages = total === 0 || perPage === null ? 0 : Math.ceil(total / perPage) || 0;
|
|
11
26
|
const isFirst = page === 1;
|
|
12
|
-
const isLast = page === numPages;
|
|
13
|
-
const prevPage = page - 1 || null;
|
|
14
|
-
const nextPage = page + 1 <= numPages ? page + 1 : null;
|
|
27
|
+
const isLast = numPages > 0 && page === numPages;
|
|
28
|
+
const prevPage = numPages === 0 ? null : page - 1 || null;
|
|
29
|
+
const nextPage = numPages > 0 && page + 1 <= numPages ? page + 1 : null;
|
|
15
30
|
return {
|
|
16
31
|
page,
|
|
17
32
|
prevPage,
|
|
@@ -6,7 +6,19 @@ interface MysqlClient {
|
|
|
6
6
|
interface MysqlAsyncClient {
|
|
7
7
|
query: (sql: string, bindings: any[]) => Promise<[Array<Record<string, any>>, any[]]>;
|
|
8
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* Run the given query with mysql and return the results
|
|
11
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
12
|
+
* @param query The SelectBuilder query to run
|
|
13
|
+
*/
|
|
9
14
|
export declare function runMysql(mysqlClient: MysqlClient, query: SelectBuilder): Promise<[Array<Record<string, any>>, any[]]>;
|
|
15
|
+
/**
|
|
16
|
+
* Run the given query with mysql and return the results, count, and pagination
|
|
17
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
18
|
+
* @param query The SelectBuilder query to run
|
|
19
|
+
* @param options
|
|
20
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
21
|
+
*/
|
|
10
22
|
export declare function runMysqlWithCount(mysqlClient: MysqlClient, query: SelectBuilder, { countExpr }?: {
|
|
11
23
|
countExpr?: string;
|
|
12
24
|
}): Promise<{
|
|
@@ -10,10 +10,22 @@ function _queryToPromise(mysqlClient, sql, bindings) {
|
|
|
10
10
|
});
|
|
11
11
|
});
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* Run the given query with mysql and return the results
|
|
15
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
16
|
+
* @param query The SelectBuilder query to run
|
|
17
|
+
*/
|
|
13
18
|
export async function runMysql(mysqlClient, query) {
|
|
14
19
|
const { sql, bindings } = query.compile({ engine: 'mysql' });
|
|
15
20
|
return _queryToPromise(mysqlClient, sql, bindings);
|
|
16
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Run the given query with mysql and return the results, count, and pagination
|
|
24
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
25
|
+
* @param query The SelectBuilder query to run
|
|
26
|
+
* @param options
|
|
27
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
28
|
+
*/
|
|
17
29
|
export async function runMysqlWithCount(mysqlClient, query, { countExpr = '*' } = {}) {
|
|
18
30
|
const { sql, bindings } = query.compile({ engine: 'mysql' });
|
|
19
31
|
const [records, fields] = await _queryToPromise(mysqlClient, sql, bindings);
|
|
@@ -3,9 +3,24 @@ import type { EngineStyle } from '../SelectBuilder/SelectBuilder';
|
|
|
3
3
|
interface PrismaClient {
|
|
4
4
|
$queryRawUnsafe: (sql: string, ...bindings: any[]) => Promise<Array<Record<string, any>>>;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Run the given query with prisma and return the results
|
|
8
|
+
* @param prisma The prisma client instance
|
|
9
|
+
* @param query The SelectBuilder query to run
|
|
10
|
+
* @param options
|
|
11
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
12
|
+
*/
|
|
6
13
|
export declare function runPrisma(prisma: PrismaClient, query: SelectBuilder, { engine }?: {
|
|
7
14
|
engine?: EngineStyle;
|
|
8
15
|
}): Promise<Array<Record<string, any>>>;
|
|
16
|
+
/**
|
|
17
|
+
* Run the given query with prisma and return the results, count, and pagination
|
|
18
|
+
* @param prisma The prisma client instance
|
|
19
|
+
* @param query The SelectBuilder query to run
|
|
20
|
+
* @param options
|
|
21
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
22
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
23
|
+
*/
|
|
9
24
|
export declare function runPrismaWithCount(prisma: PrismaClient, query: SelectBuilder, { engine, countExpr, }?: {
|
|
10
25
|
engine?: EngineStyle;
|
|
11
26
|
countExpr?: string;
|
|
@@ -1,15 +1,31 @@
|
|
|
1
1
|
import getPagination from '../getPagination/getPagination';
|
|
2
|
+
import { toSafeJsonValue } from '../toSafeJson/toSafeJson';
|
|
3
|
+
/**
|
|
4
|
+
* Run the given query with prisma and return the results
|
|
5
|
+
* @param prisma The prisma client instance
|
|
6
|
+
* @param query The SelectBuilder query to run
|
|
7
|
+
* @param options
|
|
8
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
9
|
+
*/
|
|
2
10
|
export async function runPrisma(prisma, query, { engine = undefined } = {}) {
|
|
3
11
|
const { sql, bindings } = query.compile({ engine });
|
|
4
12
|
return prisma.$queryRawUnsafe(sql, ...bindings);
|
|
5
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Run the given query with prisma and return the results, count, and pagination
|
|
16
|
+
* @param prisma The prisma client instance
|
|
17
|
+
* @param query The SelectBuilder query to run
|
|
18
|
+
* @param options
|
|
19
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
20
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
21
|
+
*/
|
|
6
22
|
export async function runPrismaWithCount(prisma, query, { engine = undefined, countExpr = '*', } = {}) {
|
|
7
23
|
const { sql, bindings } = query.compile({ engine });
|
|
8
24
|
const records = await prisma.$queryRawUnsafe(sql, ...bindings);
|
|
9
25
|
if (records.length > 0) {
|
|
10
26
|
const { sql, bindings } = query.compileCount({ engine, countExpr });
|
|
11
27
|
const result = await prisma.$queryRawUnsafe(sql, ...bindings);
|
|
12
|
-
const total = result[0].found_rows;
|
|
28
|
+
const total = toSafeJsonValue(result[0].found_rows);
|
|
13
29
|
return { records, total, pagination: getPagination(query, total) };
|
|
14
30
|
}
|
|
15
31
|
else {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type SafeValue<T> = T extends BigInt ? number | string : T extends Date ? string : T;
|
|
2
|
+
type SafeRecord<T> = Record<string, SafeValue<T>>;
|
|
3
|
+
type SafeRecords<T> = Array<SafeRecord<T>>;
|
|
4
|
+
/**
|
|
5
|
+
* Convert a value to a value that can be safely serialized to JSON
|
|
6
|
+
* @param value
|
|
7
|
+
*/
|
|
8
|
+
export declare function toSafeJsonValue<T>(value: T): SafeValue<T>;
|
|
9
|
+
/**
|
|
10
|
+
* Convert a record to one that can be safely serialized to JSON
|
|
11
|
+
* @param record
|
|
12
|
+
*/
|
|
13
|
+
export declare function toSafeJsonRecord<T extends Record<string, any>>(record: T): SafeRecord<T>;
|
|
14
|
+
/**
|
|
15
|
+
* Convert a recordset to one that can be safely serialized to JSON
|
|
16
|
+
* @param records
|
|
17
|
+
*/
|
|
18
|
+
export declare function toSafeJsonRecords<T extends Record<string, any>>(records: T[]): SafeRecords<T>;
|
|
19
|
+
/**
|
|
20
|
+
* JSON stringify a value or data structure, ensuring first that all values are
|
|
21
|
+
* safe to serialize
|
|
22
|
+
* @param records
|
|
23
|
+
*/
|
|
24
|
+
export declare function toSafeJsonString(records: Array<Record<string, any>>): string;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a value to a value that can be safely serialized to JSON
|
|
3
|
+
* @param value
|
|
4
|
+
*/
|
|
5
|
+
export function toSafeJsonValue(value) {
|
|
6
|
+
if (typeof value === 'bigint') {
|
|
7
|
+
const asNumber = Number(value);
|
|
8
|
+
// Use double equals instead of triple equals below to check if the BigInt
|
|
9
|
+
// is too big to fit in a JS Number.
|
|
10
|
+
// If so, then return it as a string, otherwise return it as a Number
|
|
11
|
+
// @ts-expect-error
|
|
12
|
+
return value == asNumber ? asNumber : value.toString();
|
|
13
|
+
}
|
|
14
|
+
if (value instanceof Date) {
|
|
15
|
+
return value.toISOString();
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Convert a record to one that can be safely serialized to JSON
|
|
21
|
+
* @param record
|
|
22
|
+
*/
|
|
23
|
+
export function toSafeJsonRecord(record) {
|
|
24
|
+
const newRecord = {};
|
|
25
|
+
for (const key in record) {
|
|
26
|
+
/* v8 ignore next 3 */
|
|
27
|
+
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
newRecord[key] = toSafeJsonValue(record[key]);
|
|
31
|
+
}
|
|
32
|
+
return newRecord;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert a recordset to one that can be safely serialized to JSON
|
|
36
|
+
* @param records
|
|
37
|
+
*/
|
|
38
|
+
export function toSafeJsonRecords(records) {
|
|
39
|
+
return records.map(toSafeJsonRecord);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* JSON stringify a value or data structure, ensuring first that all values are
|
|
43
|
+
* safe to serialize
|
|
44
|
+
* @param records
|
|
45
|
+
*/
|
|
46
|
+
export function toSafeJsonString(records) {
|
|
47
|
+
return JSON.stringify(records, (_, value) => toSafeJsonValue(value));
|
|
48
|
+
}
|
package/index.ts
CHANGED
|
@@ -2,5 +2,18 @@ export { default as Parser } from './src/Parser/Parser';
|
|
|
2
2
|
export { default as SelectBuilder } from './src/SelectBuilder/SelectBuilder';
|
|
3
3
|
export { default as extractGrouped } from './src/extractGrouped/extractGrouped';
|
|
4
4
|
export { default as extractIndexed } from './src/extractIndexed/extractIndexed';
|
|
5
|
+
export { default as extractLookup } from './src/extractLookup/extractLookup';
|
|
5
6
|
export { default as getPagination } from './src/getPagination/getPagination';
|
|
7
|
+
export {
|
|
8
|
+
runMysql,
|
|
9
|
+
runMysqlAsync,
|
|
10
|
+
runMysqlAsyncWithCount,
|
|
11
|
+
runMysqlWithCount,
|
|
12
|
+
} from './src/runMysql/runMysql';
|
|
6
13
|
export { runPrisma, runPrismaWithCount } from './src/runPrisma/runPrisma';
|
|
14
|
+
export {
|
|
15
|
+
toSafeJsonRecord,
|
|
16
|
+
toSafeJsonRecords,
|
|
17
|
+
toSafeJsonString,
|
|
18
|
+
toSafeJsonValue,
|
|
19
|
+
} from './src/toSafeJson/toSafeJson';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sql-love",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Build SQL queries with JavaScript",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -32,11 +32,11 @@
|
|
|
32
32
|
},
|
|
33
33
|
"homepage": "https://github.com/kensnyder/sql-love#readme",
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@vitest/coverage-v8": "
|
|
36
|
-
"prettier": "3.
|
|
35
|
+
"@vitest/coverage-v8": "1.3.1",
|
|
36
|
+
"prettier": "3.2.5",
|
|
37
37
|
"prettier-plugin-organize-imports": "3.2.4",
|
|
38
38
|
"rimraf": "5.0.5",
|
|
39
|
-
"typescript": "5.3.
|
|
40
|
-
"vitest": "
|
|
39
|
+
"typescript": "5.3.3",
|
|
40
|
+
"vitest": "1.3.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import extractLookup from './extractLookup';
|
|
2
|
+
|
|
3
|
+
describe('extractLookup()', () => {
|
|
4
|
+
it('should handle lookup by number', () => {
|
|
5
|
+
const records = [
|
|
6
|
+
{ id: 1, name: 'John', age: 30 },
|
|
7
|
+
{ id: 2, name: 'Jane', age: 35 },
|
|
8
|
+
];
|
|
9
|
+
const nameById = extractLookup('id', 'name', records);
|
|
10
|
+
expect(nameById).toEqual({ '1': 'John', '2': 'Jane' });
|
|
11
|
+
});
|
|
12
|
+
it('should handle lookups with non-unique fields', () => {
|
|
13
|
+
const records = [
|
|
14
|
+
{ id: 1, name: 'John', age: 30 },
|
|
15
|
+
{ id: 2, name: 'Jane', age: 30 },
|
|
16
|
+
];
|
|
17
|
+
const ageById = extractLookup('age', 'name', records);
|
|
18
|
+
expect(ageById).toEqual({ '30': 'Jane' });
|
|
19
|
+
});
|
|
20
|
+
it('should throw if key field is not present', () => {
|
|
21
|
+
const records = [
|
|
22
|
+
{ id: 1, name: 'John', age: 30 },
|
|
23
|
+
{ id: 2, name: 'Jane' },
|
|
24
|
+
];
|
|
25
|
+
// @ts-expect-error
|
|
26
|
+
const thrower = () => extractLookup('age', 'name', records);
|
|
27
|
+
expect(thrower).toThrow();
|
|
28
|
+
});
|
|
29
|
+
it('should throw if value field is not present', () => {
|
|
30
|
+
const records = [
|
|
31
|
+
{ id: 1, name: 'John', age: 30 },
|
|
32
|
+
{ id: 2, name: 'Jane' },
|
|
33
|
+
];
|
|
34
|
+
// @ts-expect-error
|
|
35
|
+
const thrower = () => extractLookup('id', 'age', records);
|
|
36
|
+
expect(thrower).toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Given an array of records, extract an object with keyField-valueField pairs
|
|
3
|
+
* @param records An array of records
|
|
4
|
+
* @param keyField The field to use as the key
|
|
5
|
+
* @param valueField The field to use as the value
|
|
6
|
+
*/
|
|
7
|
+
export default function extractLookup<
|
|
8
|
+
T extends Array<Record<K | V, any>>,
|
|
9
|
+
K extends keyof T[number],
|
|
10
|
+
V extends keyof T[number],
|
|
11
|
+
>(
|
|
12
|
+
keyField: K,
|
|
13
|
+
valueField: V,
|
|
14
|
+
records: T
|
|
15
|
+
): { [key in T[number][K]]: T[number][V] } {
|
|
16
|
+
const lookup: { [key in T[number][K]]: T[number][V] } = {} as any;
|
|
17
|
+
let idx = 0;
|
|
18
|
+
for (const record of records) {
|
|
19
|
+
if (!(keyField in record)) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Column "${String(keyField)}" is not present in record ${idx}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
if (!(valueField in record)) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Column "${String(valueField)}" is not present in record ${idx}`
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
idx++;
|
|
30
|
+
lookup[String(record[keyField])] = record[valueField];
|
|
31
|
+
}
|
|
32
|
+
return lookup;
|
|
33
|
+
}
|
|
@@ -17,6 +17,20 @@ describe('getPagination()', () => {
|
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
19
|
it('should handle 42 results with offset', () => {
|
|
20
|
+
const query = new SelectBuilder('SELECT * FROM users LIMIT 10').offset(20);
|
|
21
|
+
const pagination = getPagination(query, 42n);
|
|
22
|
+
expect(pagination).toEqual({
|
|
23
|
+
page: 3,
|
|
24
|
+
prevPage: 2,
|
|
25
|
+
nextPage: 4,
|
|
26
|
+
perPage: 10,
|
|
27
|
+
numPages: 5,
|
|
28
|
+
total: 42,
|
|
29
|
+
isFirst: false,
|
|
30
|
+
isLast: false,
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
it('should handle 42 results with page', () => {
|
|
20
34
|
const query = new SelectBuilder('SELECT * FROM users LIMIT 10').page(3);
|
|
21
35
|
const pagination = getPagination(query, 42n);
|
|
22
36
|
expect(pagination).toEqual({
|
|
@@ -34,13 +48,13 @@ describe('getPagination()', () => {
|
|
|
34
48
|
const query = new SelectBuilder('SELECT * FROM users LIMIT 10');
|
|
35
49
|
const pagination = getPagination(query, 0);
|
|
36
50
|
expect(pagination).toEqual({
|
|
37
|
-
page:
|
|
51
|
+
page: null,
|
|
38
52
|
prevPage: null,
|
|
39
53
|
nextPage: null,
|
|
40
54
|
perPage: 10,
|
|
41
55
|
numPages: 0,
|
|
42
56
|
total: 0,
|
|
43
|
-
isFirst:
|
|
57
|
+
isFirst: false,
|
|
44
58
|
isLast: false,
|
|
45
59
|
});
|
|
46
60
|
});
|
|
@@ -48,13 +62,13 @@ describe('getPagination()', () => {
|
|
|
48
62
|
const query = new SelectBuilder('SELECT * FROM users');
|
|
49
63
|
const pagination = getPagination(query, 42);
|
|
50
64
|
expect(pagination).toEqual({
|
|
51
|
-
page:
|
|
65
|
+
page: null,
|
|
52
66
|
prevPage: null,
|
|
53
67
|
nextPage: null,
|
|
54
|
-
perPage:
|
|
68
|
+
perPage: null,
|
|
55
69
|
numPages: 0,
|
|
56
70
|
total: 42,
|
|
57
|
-
isFirst:
|
|
71
|
+
isFirst: false,
|
|
58
72
|
isLast: false,
|
|
59
73
|
});
|
|
60
74
|
});
|
|
@@ -10,13 +10,25 @@ export default function getPagination(
|
|
|
10
10
|
total: BigInt | number
|
|
11
11
|
) {
|
|
12
12
|
total = Number(total) || 0;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const perPage = Number(query._limit) || null;
|
|
14
|
+
let page: number | null = null;
|
|
15
|
+
if (total === 0) {
|
|
16
|
+
page = null;
|
|
17
|
+
} else if (query._page) {
|
|
18
|
+
page = Number(query._page);
|
|
19
|
+
} else if (query._limit && query._offset) {
|
|
20
|
+
page = Math.floor(Number(query._offset) / Number(query._limit)) + 1;
|
|
21
|
+
} else if (query._limit && !query._offset) {
|
|
22
|
+
page = 1;
|
|
23
|
+
} else {
|
|
24
|
+
page = null;
|
|
25
|
+
}
|
|
26
|
+
const numPages =
|
|
27
|
+
total === 0 || perPage === null ? 0 : Math.ceil(total / perPage) || 0;
|
|
16
28
|
const isFirst = page === 1;
|
|
17
|
-
const isLast = page === numPages;
|
|
18
|
-
const prevPage = page - 1 || null;
|
|
19
|
-
const nextPage = page + 1 <= numPages ? page + 1 : null;
|
|
29
|
+
const isLast = numPages > 0 && page === numPages;
|
|
30
|
+
const prevPage = numPages === 0 ? null : page - 1 || null;
|
|
31
|
+
const nextPage = numPages > 0 && page + 1 <= numPages ? page + 1 : null;
|
|
20
32
|
return {
|
|
21
33
|
page,
|
|
22
34
|
prevPage,
|
|
@@ -41,7 +41,33 @@ describe('runMysql', () => {
|
|
|
41
41
|
expect(results).toBe(mockResults);
|
|
42
42
|
expect(fields).toBe(mockFields);
|
|
43
43
|
});
|
|
44
|
-
it('should
|
|
44
|
+
it('should handle errors', async () => {
|
|
45
|
+
const client = {
|
|
46
|
+
query: vi.fn(
|
|
47
|
+
(
|
|
48
|
+
sql: string,
|
|
49
|
+
bindings: any[],
|
|
50
|
+
callback: (
|
|
51
|
+
err: Error | null,
|
|
52
|
+
rows: Array<Record<string, any>>,
|
|
53
|
+
fields: any[]
|
|
54
|
+
) => void
|
|
55
|
+
) => {
|
|
56
|
+
callback(new Error('Test Error'), [], []);
|
|
57
|
+
}
|
|
58
|
+
),
|
|
59
|
+
};
|
|
60
|
+
const query = new SelectBuilder('SELECT * FROM users').where(
|
|
61
|
+
'dept',
|
|
62
|
+
'Marketing'
|
|
63
|
+
);
|
|
64
|
+
try {
|
|
65
|
+
await runMysql(client, query);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
expect(e.message).toBe('Test Error');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
it('should run with count', async () => {
|
|
45
71
|
const mockResults1 = [
|
|
46
72
|
{ id: 1, name: 'John', dept: 'Marketing' },
|
|
47
73
|
{ id: 2, name: 'Jane', dept: 'Marketing' },
|
|
@@ -93,7 +119,53 @@ describe('runMysql', () => {
|
|
|
93
119
|
isLast: true,
|
|
94
120
|
});
|
|
95
121
|
});
|
|
96
|
-
it('should
|
|
122
|
+
it('should run when count is 0', async () => {
|
|
123
|
+
const mockResults1 = [];
|
|
124
|
+
const mockFields1 = [{ name: 'id' }, { name: 'name' }, { name: 'dept' }];
|
|
125
|
+
const mockResults2 = [{ found_rows: 0 }];
|
|
126
|
+
const mockFields2 = [{ name: 'found_rows' }];
|
|
127
|
+
let callCount = 0;
|
|
128
|
+
const client = {
|
|
129
|
+
query: vi.fn(
|
|
130
|
+
(
|
|
131
|
+
sql: string,
|
|
132
|
+
bindings: any[],
|
|
133
|
+
callback: (
|
|
134
|
+
err: Error | null,
|
|
135
|
+
rows: Array<Record<string, any>>,
|
|
136
|
+
fields: any[]
|
|
137
|
+
) => void
|
|
138
|
+
) => {
|
|
139
|
+
if (++callCount === 1) {
|
|
140
|
+
callback(null, mockResults1, mockFields1);
|
|
141
|
+
}
|
|
142
|
+
callback(null, mockResults2, mockFields2);
|
|
143
|
+
}
|
|
144
|
+
),
|
|
145
|
+
};
|
|
146
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
147
|
+
.where('dept', 'Marketing')
|
|
148
|
+
.limit(2)
|
|
149
|
+
.page(5);
|
|
150
|
+
const { records, total, pagination } = await runMysqlWithCount(
|
|
151
|
+
client,
|
|
152
|
+
query
|
|
153
|
+
);
|
|
154
|
+
expect(callCount).toBe(1);
|
|
155
|
+
expect(records).toBe(mockResults1);
|
|
156
|
+
expect(total).toBe(0);
|
|
157
|
+
expect(pagination).toEqual({
|
|
158
|
+
page: null,
|
|
159
|
+
prevPage: null,
|
|
160
|
+
nextPage: null,
|
|
161
|
+
perPage: 2,
|
|
162
|
+
numPages: 0,
|
|
163
|
+
total: 0,
|
|
164
|
+
isFirst: false,
|
|
165
|
+
isLast: false,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it('should invoke client.query with correct args (promises)', async () => {
|
|
97
169
|
const mockResults = [
|
|
98
170
|
{ id: 1, name: 'John', dept: 'Marketing' },
|
|
99
171
|
{ id: 2, name: 'Jane', dept: 'Marketing' },
|
|
@@ -117,7 +189,7 @@ describe('runMysql', () => {
|
|
|
117
189
|
expect(results).toBe(mockResults);
|
|
118
190
|
expect(fields).toBe(mockFields);
|
|
119
191
|
});
|
|
120
|
-
it('should
|
|
192
|
+
it('should run with count (promises)', async () => {
|
|
121
193
|
const mockResults1 = [
|
|
122
194
|
{ id: 1, name: 'John', dept: 'Marketing' },
|
|
123
195
|
{ id: 2, name: 'Jane', dept: 'Marketing' },
|
|
@@ -143,10 +215,7 @@ describe('runMysql', () => {
|
|
|
143
215
|
client,
|
|
144
216
|
query
|
|
145
217
|
);
|
|
146
|
-
expect(
|
|
147
|
-
'SELECT\n *\nFROM users\nWHERE dept = ?\nOFFSET 8\nLIMIT 2',
|
|
148
|
-
['Marketing']
|
|
149
|
-
);
|
|
218
|
+
expect(callCount).toBe(2);
|
|
150
219
|
expect(records).toBe(mockResults1);
|
|
151
220
|
expect(total).toBe(9);
|
|
152
221
|
expect(pagination).toEqual({
|
|
@@ -161,4 +230,57 @@ describe('runMysql', () => {
|
|
|
161
230
|
});
|
|
162
231
|
expect(fields).toEqual(mockFields1);
|
|
163
232
|
});
|
|
233
|
+
it('should run with count of zero (promises)', async () => {
|
|
234
|
+
const mockResults1 = [];
|
|
235
|
+
const mockFields1 = [{ name: 'id' }, { name: 'name' }, { name: 'dept' }];
|
|
236
|
+
const mockResults2 = [{ found_rows: 0 }];
|
|
237
|
+
const mockFields2 = [{ name: 'found_rows' }];
|
|
238
|
+
let callCount = 0;
|
|
239
|
+
const client = {
|
|
240
|
+
query: vi.fn((sql: string, bindings: any[]) => {
|
|
241
|
+
if (++callCount === 1) {
|
|
242
|
+
return Promise.resolve([mockResults1, mockFields1]);
|
|
243
|
+
}
|
|
244
|
+
return Promise.resolve([mockResults2, mockFields2]);
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
248
|
+
.where('dept', 'Marketing')
|
|
249
|
+
.limit(2)
|
|
250
|
+
.page(5);
|
|
251
|
+
const { records, total, pagination } = await runMysqlAsyncWithCount(
|
|
252
|
+
// @ts-expect-error
|
|
253
|
+
client,
|
|
254
|
+
query
|
|
255
|
+
);
|
|
256
|
+
expect(callCount).toBe(1);
|
|
257
|
+
expect(records).toBe(mockResults1);
|
|
258
|
+
expect(total).toBe(0);
|
|
259
|
+
expect(pagination).toEqual({
|
|
260
|
+
page: null,
|
|
261
|
+
prevPage: null,
|
|
262
|
+
nextPage: null,
|
|
263
|
+
perPage: 2,
|
|
264
|
+
numPages: 0,
|
|
265
|
+
total: 0,
|
|
266
|
+
isFirst: false,
|
|
267
|
+
isLast: false,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
it('should handle errors (promises)', async () => {
|
|
271
|
+
const client = {
|
|
272
|
+
query: vi.fn((sql: string, bindings: any[]) => {
|
|
273
|
+
return Promise.reject('Test Test');
|
|
274
|
+
}),
|
|
275
|
+
};
|
|
276
|
+
const query = new SelectBuilder('SELECT * FROM users').where(
|
|
277
|
+
'dept',
|
|
278
|
+
'Marketing'
|
|
279
|
+
);
|
|
280
|
+
try {
|
|
281
|
+
await runMysqlAsync(client, query);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
expect(e).toBe('Test Test');
|
|
284
|
+
}
|
|
285
|
+
});
|
|
164
286
|
});
|
package/src/runMysql/runMysql.ts
CHANGED
|
@@ -37,6 +37,11 @@ function _queryToPromise(
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Run the given query with mysql and return the results
|
|
42
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
43
|
+
* @param query The SelectBuilder query to run
|
|
44
|
+
*/
|
|
40
45
|
export async function runMysql(
|
|
41
46
|
mysqlClient: MysqlClient,
|
|
42
47
|
query: SelectBuilder
|
|
@@ -45,6 +50,13 @@ export async function runMysql(
|
|
|
45
50
|
return _queryToPromise(mysqlClient, sql, bindings);
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Run the given query with mysql and return the results, count, and pagination
|
|
55
|
+
* @param mysqlClient A client created with mysql.createConnection() or mysql.createPool()
|
|
56
|
+
* @param query The SelectBuilder query to run
|
|
57
|
+
* @param options
|
|
58
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
59
|
+
*/
|
|
48
60
|
export async function runMysqlWithCount(
|
|
49
61
|
mysqlClient: MysqlClient,
|
|
50
62
|
query: SelectBuilder,
|
|
@@ -64,4 +64,36 @@ describe('runPrisma', () => {
|
|
|
64
64
|
isLast: true,
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
|
+
it('should handle when recordset is empty', async () => {
|
|
68
|
+
const prisma = {
|
|
69
|
+
$queryRawUnsafe: vi.fn(() => []),
|
|
70
|
+
};
|
|
71
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
72
|
+
.where('dept', 'Marketing')
|
|
73
|
+
.limit(2)
|
|
74
|
+
.page(5);
|
|
75
|
+
const { records, total, pagination } = await runPrismaWithCount(
|
|
76
|
+
// @ts-expect-error
|
|
77
|
+
prisma,
|
|
78
|
+
query
|
|
79
|
+
);
|
|
80
|
+
// @ts-expect-error
|
|
81
|
+
expect(prisma.$queryRawUnsafe.mock.calls[0][0]).toBe(
|
|
82
|
+
'SELECT\n *\nFROM users\nWHERE dept = ?\nOFFSET 8\nLIMIT 2'
|
|
83
|
+
);
|
|
84
|
+
// @ts-expect-error
|
|
85
|
+
expect(prisma.$queryRawUnsafe.mock.calls[0][1]).toBe('Marketing');
|
|
86
|
+
expect(records).toStrictEqual([]);
|
|
87
|
+
expect(total).toBe(0);
|
|
88
|
+
expect(pagination).toEqual({
|
|
89
|
+
page: null,
|
|
90
|
+
prevPage: null,
|
|
91
|
+
nextPage: null,
|
|
92
|
+
perPage: 2,
|
|
93
|
+
numPages: 0,
|
|
94
|
+
total: 0,
|
|
95
|
+
isFirst: false,
|
|
96
|
+
isLast: false,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
67
99
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type SelectBuilder from '../SelectBuilder/SelectBuilder';
|
|
2
2
|
import type { EngineStyle } from '../SelectBuilder/SelectBuilder';
|
|
3
3
|
import getPagination from '../getPagination/getPagination';
|
|
4
|
+
import { toSafeJsonValue } from '../toSafeJson/toSafeJson';
|
|
4
5
|
|
|
5
6
|
interface PrismaClient {
|
|
6
7
|
$queryRawUnsafe: (
|
|
@@ -9,29 +10,64 @@ interface PrismaClient {
|
|
|
9
10
|
) => Promise<Array<Record<string, any>>>;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Run the given query with prisma and return the results
|
|
15
|
+
* @param prisma The prisma client instance
|
|
16
|
+
* @param query The SelectBuilder query to run
|
|
17
|
+
* @param options
|
|
18
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
19
|
+
*/
|
|
20
|
+
export async function runPrisma<
|
|
21
|
+
T extends Array<Record<string, any>> = Array<Record<string, any>>,
|
|
22
|
+
>(
|
|
13
23
|
prisma: PrismaClient,
|
|
14
24
|
query: SelectBuilder,
|
|
15
25
|
{ engine = undefined }: { engine?: EngineStyle } = {}
|
|
16
|
-
): Promise<
|
|
26
|
+
): Promise<T> {
|
|
17
27
|
const { sql, bindings } = query.compile({ engine });
|
|
18
|
-
return prisma.$queryRawUnsafe(sql, ...bindings)
|
|
28
|
+
return prisma.$queryRawUnsafe(sql, ...bindings) as unknown as Promise<T>;
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Run the given query with prisma and return the results, count, and pagination
|
|
33
|
+
* @param prisma The prisma client instance
|
|
34
|
+
* @param query The SelectBuilder query to run
|
|
35
|
+
* @param options
|
|
36
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
37
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
38
|
+
*/
|
|
39
|
+
export async function runPrismaWithCount<
|
|
40
|
+
T extends Array<Record<string, any>> = Array<Record<string, any>>,
|
|
41
|
+
>(
|
|
22
42
|
prisma: PrismaClient,
|
|
23
43
|
query: SelectBuilder,
|
|
24
44
|
{
|
|
25
45
|
engine = undefined,
|
|
26
46
|
countExpr = '*',
|
|
27
47
|
}: { engine?: EngineStyle; countExpr?: string } = {}
|
|
28
|
-
) {
|
|
48
|
+
): Promise<{
|
|
49
|
+
records: T;
|
|
50
|
+
total: number;
|
|
51
|
+
pagination: {
|
|
52
|
+
page: number;
|
|
53
|
+
prevPage: number;
|
|
54
|
+
nextPage: number;
|
|
55
|
+
perPage: number;
|
|
56
|
+
numPages: number;
|
|
57
|
+
total: number;
|
|
58
|
+
isFirst: boolean;
|
|
59
|
+
isLast: boolean;
|
|
60
|
+
};
|
|
61
|
+
}> {
|
|
29
62
|
const { sql, bindings } = query.compile({ engine });
|
|
30
|
-
const records = await prisma.$queryRawUnsafe(
|
|
63
|
+
const records = (await prisma.$queryRawUnsafe(
|
|
64
|
+
sql,
|
|
65
|
+
...bindings
|
|
66
|
+
)) as unknown as T;
|
|
31
67
|
if (records.length > 0) {
|
|
32
68
|
const { sql, bindings } = query.compileCount({ engine, countExpr });
|
|
33
69
|
const result = await prisma.$queryRawUnsafe(sql, ...bindings);
|
|
34
|
-
const total = result[0].found_rows;
|
|
70
|
+
const total = toSafeJsonValue(result[0].found_rows);
|
|
35
71
|
return { records, total, pagination: getPagination(query, total) };
|
|
36
72
|
} else {
|
|
37
73
|
return { records, total: 0, pagination: getPagination(query, 0) };
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Count the number of times a substring appears in a string
|
|
3
|
+
* @param haystack
|
|
4
|
+
* @param needle
|
|
5
|
+
*/
|
|
1
6
|
export default function substrCount(haystack: string, needle: string) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
let count = 0;
|
|
8
|
+
let pos = haystack.indexOf(needle);
|
|
9
|
+
while (pos !== -1) {
|
|
10
|
+
count += 1;
|
|
11
|
+
pos = haystack.indexOf(needle, pos + 1);
|
|
12
|
+
}
|
|
13
|
+
return count;
|
|
9
14
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { toSafeJsonRecords, toSafeJsonString } from './toSafeJson';
|
|
2
|
+
|
|
3
|
+
describe('toSafeJsonString()', () => {
|
|
4
|
+
it('should convert small BigInt to Number', () => {
|
|
5
|
+
const records = [{ found_rows: 30n }];
|
|
6
|
+
const json = toSafeJsonString(records);
|
|
7
|
+
expect(json).toBe('[{"found_rows":30}]');
|
|
8
|
+
});
|
|
9
|
+
it('should convert large BigInt to String', () => {
|
|
10
|
+
const myBigInt = BigInt(String(Number.MAX_SAFE_INTEGER) + '0');
|
|
11
|
+
const records = [{ found_rows: myBigInt }];
|
|
12
|
+
const json = toSafeJsonString(records);
|
|
13
|
+
expect(json).toBe(`[{"found_rows":"${myBigInt.toString()}"}]`);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('jsonSafePrisma()', () => {
|
|
17
|
+
it('should convert small BigInt to Number', () => {
|
|
18
|
+
const records = [{ found_rows: 30n }];
|
|
19
|
+
const json = toSafeJsonRecords(records);
|
|
20
|
+
expect(json).toStrictEqual([{ found_rows: 30 }]);
|
|
21
|
+
});
|
|
22
|
+
it('should convert large BigInt to String', () => {
|
|
23
|
+
const myBigInt = BigInt(String(Number.MAX_SAFE_INTEGER) + '0');
|
|
24
|
+
const records = [{ found_rows: myBigInt }];
|
|
25
|
+
const json = toSafeJsonRecords(records);
|
|
26
|
+
expect(json).toStrictEqual([{ found_rows: myBigInt.toString() }]);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type SafeValue<T> = T extends BigInt ? number | string : T;
|
|
2
|
+
type SafeRecord<T> = Record<string, SafeValue<T>>;
|
|
3
|
+
type SafeRecords<T> = Array<SafeRecord<T>>;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert a value to a value that can be safely serialized to JSON
|
|
7
|
+
* @param value
|
|
8
|
+
*/
|
|
9
|
+
export function toSafeJsonValue<T>(value: T): SafeValue<T> {
|
|
10
|
+
if (typeof value === 'bigint') {
|
|
11
|
+
const asNumber = Number(value);
|
|
12
|
+
// Use double equals instead of triple equals below to check if the BigInt
|
|
13
|
+
// is too big to fit in a JS Number.
|
|
14
|
+
// If so, then return it as a string, otherwise return it as a Number
|
|
15
|
+
// @ts-expect-error
|
|
16
|
+
return value == asNumber ? asNumber : value.toString();
|
|
17
|
+
}
|
|
18
|
+
return value as SafeValue<T>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert a record to one that can be safely serialized to JSON
|
|
23
|
+
* @param record
|
|
24
|
+
*/
|
|
25
|
+
export function toSafeJsonRecord<T extends Record<string, any>>(
|
|
26
|
+
record: T
|
|
27
|
+
): SafeRecord<T> {
|
|
28
|
+
const newRecord: SafeValue<T> = {} as any;
|
|
29
|
+
for (const key in record) {
|
|
30
|
+
/* v8 ignore next 3 */
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
newRecord[key] = toSafeJsonValue(record[key]);
|
|
35
|
+
}
|
|
36
|
+
return newRecord;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Convert a recordset to one that can be safely serialized to JSON
|
|
41
|
+
* @param records
|
|
42
|
+
*/
|
|
43
|
+
export function toSafeJsonRecords<T extends Record<string, any>>(
|
|
44
|
+
records: T[]
|
|
45
|
+
): SafeRecords<T> {
|
|
46
|
+
return records.map(toSafeJsonRecord);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* JSON stringify a value or data structure, ensuring first that all values are
|
|
51
|
+
* safe to serialize
|
|
52
|
+
* @param records
|
|
53
|
+
*/
|
|
54
|
+
export function toSafeJsonString(records: Array<Record<string, any>>) {
|
|
55
|
+
return JSON.stringify(records, (_, value: any) => toSafeJsonValue(value));
|
|
56
|
+
}
|
package/vitest.config.ts
ADDED