sql-love 0.9.6 → 1.0.0
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 +116 -18
- 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 +17 -1
- package/src/substrCount/substrCount.ts +12 -7
- package/src/toSafeJson/toSafeJson.spec.ts +40 -0
- package/src/toSafeJson/toSafeJson.ts +63 -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.0" width="250" height="208" />
|
|
2
2
|
|
|
3
3
|
# sql-love
|
|
4
4
|
|
|
5
|
-
[](https://npmjs.com/package/sql-love)
|
|
6
|
+
[](https://npmjs.com/package/sql-love)
|
|
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
|
|
|
@@ -138,10 +139,19 @@ SelectBuilder.setDefaultEngine('pg');
|
|
|
138
139
|
You should use prepared statements to run the sql and bindings returned by
|
|
139
140
|
`SelectBuilder.compile()`. This will protect you from SQL injection.
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
Using `mysqlClient.query(sql, bindings)` and
|
|
143
|
+
`prisma.$queryRawUnsafe(sql, ...bindings)` are examples of using prepared
|
|
143
144
|
statements.
|
|
144
145
|
|
|
146
|
+
Note that even though the name `prisma.$queryRawUnsafe(sql, ...bindings)`
|
|
147
|
+
implies that the query is unsafe, it is actually safe because
|
|
148
|
+
`prisma.$queryRawUnsafe` uses prepared statements.
|
|
149
|
+
|
|
150
|
+
SQL Love also includes utility functions for running queries in mysql and
|
|
151
|
+
Prisma so you don't have to remember these functions. See documentation for
|
|
152
|
+
[runPrisma](#runprisma-and-runprismawithcount) and
|
|
153
|
+
[runMysql](#runmysql-and-runmysqlwithcount) below.
|
|
154
|
+
|
|
145
155
|
### Building the query
|
|
146
156
|
|
|
147
157
|
Use the following methods to build queries. And see
|
|
@@ -451,12 +461,31 @@ const grouped = extractGrouped('dept', records);
|
|
|
451
461
|
*/
|
|
452
462
|
```
|
|
453
463
|
|
|
464
|
+
### extractLookup
|
|
465
|
+
|
|
466
|
+
You can create a lookup object based on a results set
|
|
467
|
+
|
|
468
|
+
```js
|
|
469
|
+
const records = [
|
|
470
|
+
{ id: 1, name: 'John', age: 30 },
|
|
471
|
+
{ id: 2, name: 'Jane', age: 35 },
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
const nameById = extractLookup('id', 'name', records);
|
|
475
|
+
/*
|
|
476
|
+
{
|
|
477
|
+
'1': 'John',
|
|
478
|
+
'2': 'Jane',
|
|
479
|
+
}
|
|
480
|
+
*/
|
|
481
|
+
```
|
|
482
|
+
|
|
454
483
|
### runPrisma and runPrismaWithCount
|
|
455
484
|
|
|
456
485
|
`runPrisma()` will run a query using Prisma and return the results.
|
|
457
486
|
|
|
458
487
|
```js
|
|
459
|
-
import { SelectBuilder, runPrisma
|
|
488
|
+
import { SelectBuilder, runPrisma } from 'sql-love';
|
|
460
489
|
import { prisma } from '~/db.server';
|
|
461
490
|
|
|
462
491
|
const query = new SelectBuilder('SELECT * FROM users');
|
|
@@ -469,7 +498,7 @@ const rows = runPrisma(prisma, query, { engine: 'pg' });
|
|
|
469
498
|
found rows and pagination.
|
|
470
499
|
|
|
471
500
|
```js
|
|
472
|
-
import { SelectBuilder,
|
|
501
|
+
import { SelectBuilder, runPrismaWithCount } from 'sql-love';
|
|
473
502
|
import { prisma } from '~/db.server';
|
|
474
503
|
|
|
475
504
|
const query = new SelectBuilder('SELECT * FROM users')
|
|
@@ -485,4 +514,73 @@ See [getPagination](#getpagination) for details on the pagination object.
|
|
|
485
514
|
|
|
486
515
|
### runMysql and runMysqlWithCount
|
|
487
516
|
|
|
488
|
-
|
|
517
|
+
`runMysql()` will run a query using mysql and return the results.
|
|
518
|
+
|
|
519
|
+
```js
|
|
520
|
+
import { SelectBuilder, runMysql } from 'sql-love';
|
|
521
|
+
import { mysql } from 'mysql2';
|
|
522
|
+
const client = mysql.createConnection(config);
|
|
523
|
+
|
|
524
|
+
const query = new SelectBuilder('SELECT * FROM users');
|
|
525
|
+
query.where('dept', 'Marketing');
|
|
526
|
+
|
|
527
|
+
const rows = await runMysql(client, query);
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
`runMysqlWithCount()` will run a query using mysql and return the results,
|
|
531
|
+
found rows and pagination.
|
|
532
|
+
|
|
533
|
+
```js
|
|
534
|
+
import { SelectBuilder, runMysqlWithCount } from 'sql-love';
|
|
535
|
+
import { mysql } from 'mysql2';
|
|
536
|
+
const client = mysql.createConnection(config);
|
|
537
|
+
|
|
538
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
539
|
+
.where('dept', 'Marketing')
|
|
540
|
+
.limit(2)
|
|
541
|
+
.page(5);
|
|
542
|
+
|
|
543
|
+
const { records, total, pagination } = await runMysqlWithCount(client, query);
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
You can also use the mysql2 promise api:
|
|
547
|
+
|
|
548
|
+
```js
|
|
549
|
+
import { SelectBuilder, runMysqlAsync, runMysqlAsyncWithCount } from 'sql-love';
|
|
550
|
+
import { mysql } from 'mysql2/promise';
|
|
551
|
+
const client = await mysql.createConnection(config);
|
|
552
|
+
|
|
553
|
+
const query = new SelectBuilder('SELECT * FROM users')
|
|
554
|
+
.where('dept', 'Marketing')
|
|
555
|
+
.limit(2)
|
|
556
|
+
.page(5);
|
|
557
|
+
|
|
558
|
+
const records = await runMysqlAsync(client, query);
|
|
559
|
+
// OR
|
|
560
|
+
const { records, total, pagination } = await runMysqlAsyncWithCount(
|
|
561
|
+
client,
|
|
562
|
+
query
|
|
563
|
+
);
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### toSafeJson
|
|
567
|
+
|
|
568
|
+
With some database clients such as Prisma, your recordsets may contain Date
|
|
569
|
+
objects and BigInt objects. These are not safe to pass to JSON.stringify().
|
|
570
|
+
The following are examples of the toSafeJson\* utility functions.
|
|
571
|
+
|
|
572
|
+
```js
|
|
573
|
+
import { toSafeJsonString, toSafeJsonRecords } from 'sql-love';
|
|
574
|
+
|
|
575
|
+
const records = [{ id: 1, name: 'John', created_at: new Date(), groups: 12n }];
|
|
576
|
+
|
|
577
|
+
toSafeJsonRecords(records);
|
|
578
|
+
// [
|
|
579
|
+
// { id: 1, name: 'John', created_at: '2023-12-12T17:20:09.471Z', groups: 12 },
|
|
580
|
+
// ]
|
|
581
|
+
|
|
582
|
+
toSafeJsonString(records);
|
|
583
|
+
// '[{"id":1,"name":"John","created_at":"2023-12-12T17:20:09.471Z","groups":12}]'
|
|
584
|
+
|
|
585
|
+
// Note that BigInt values too big for Number will be converted to strings
|
|
586
|
+
```
|
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.0",
|
|
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": "0.
|
|
36
|
-
"prettier": "3.1.
|
|
35
|
+
"@vitest/coverage-v8": "1.0.4",
|
|
36
|
+
"prettier": "3.1.1",
|
|
37
37
|
"prettier-plugin-organize-imports": "3.2.4",
|
|
38
38
|
"rimraf": "5.0.5",
|
|
39
|
-
"typescript": "5.3.
|
|
40
|
-
"vitest": "0.
|
|
39
|
+
"typescript": "5.3.3",
|
|
40
|
+
"vitest": "1.0.4"
|
|
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,6 +10,13 @@ interface PrismaClient {
|
|
|
9
10
|
) => Promise<Array<Record<string, any>>>;
|
|
10
11
|
}
|
|
11
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
|
+
*/
|
|
12
20
|
export async function runPrisma(
|
|
13
21
|
prisma: PrismaClient,
|
|
14
22
|
query: SelectBuilder,
|
|
@@ -18,6 +26,14 @@ export async function runPrisma(
|
|
|
18
26
|
return prisma.$queryRawUnsafe(sql, ...bindings);
|
|
19
27
|
}
|
|
20
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Run the given query with prisma and return the results, count, and pagination
|
|
31
|
+
* @param prisma The prisma client instance
|
|
32
|
+
* @param query The SelectBuilder query to run
|
|
33
|
+
* @param options
|
|
34
|
+
* @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
|
|
35
|
+
* @param options.countExpr The SQL expression to use for the count (default "*")
|
|
36
|
+
*/
|
|
21
37
|
export async function runPrismaWithCount(
|
|
22
38
|
prisma: PrismaClient,
|
|
23
39
|
query: SelectBuilder,
|
|
@@ -31,7 +47,7 @@ export async function runPrismaWithCount(
|
|
|
31
47
|
if (records.length > 0) {
|
|
32
48
|
const { sql, bindings } = query.compileCount({ engine, countExpr });
|
|
33
49
|
const result = await prisma.$queryRawUnsafe(sql, ...bindings);
|
|
34
|
-
const total = result[0].found_rows;
|
|
50
|
+
const total = toSafeJsonValue(result[0].found_rows);
|
|
35
51
|
return { records, total, pagination: getPagination(query, total) };
|
|
36
52
|
} else {
|
|
37
53
|
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,40 @@
|
|
|
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
|
+
it('should convert Date to ISO String', () => {
|
|
16
|
+
const date = new Date('2023-12-06');
|
|
17
|
+
const records = [{ date }];
|
|
18
|
+
const json = toSafeJsonString(records);
|
|
19
|
+
expect(json).toBe(`[{"date":"${date.toISOString()}"}]`);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('jsonSafePrisma()', () => {
|
|
23
|
+
it('should convert small BigInt to Number', () => {
|
|
24
|
+
const records = [{ found_rows: 30n }];
|
|
25
|
+
const json = toSafeJsonRecords(records);
|
|
26
|
+
expect(json).toStrictEqual([{ found_rows: 30 }]);
|
|
27
|
+
});
|
|
28
|
+
it('should convert large BigInt to String', () => {
|
|
29
|
+
const myBigInt = BigInt(String(Number.MAX_SAFE_INTEGER) + '0');
|
|
30
|
+
const records = [{ found_rows: myBigInt }];
|
|
31
|
+
const json = toSafeJsonRecords(records);
|
|
32
|
+
expect(json).toStrictEqual([{ found_rows: myBigInt.toString() }]);
|
|
33
|
+
});
|
|
34
|
+
it('should convert Date to ISO String', () => {
|
|
35
|
+
const date = new Date('2023-12-06');
|
|
36
|
+
const records = [{ date }];
|
|
37
|
+
const json = toSafeJsonRecords(records);
|
|
38
|
+
expect(json).toStrictEqual([{ date: date.toISOString() }]);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type SafeValue<T> = T extends BigInt
|
|
2
|
+
? number | string
|
|
3
|
+
: T extends Date
|
|
4
|
+
? string
|
|
5
|
+
: T;
|
|
6
|
+
type SafeRecord<T> = Record<string, SafeValue<T>>;
|
|
7
|
+
type SafeRecords<T> = Array<SafeRecord<T>>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Convert a value to a value that can be safely serialized to JSON
|
|
11
|
+
* @param value
|
|
12
|
+
*/
|
|
13
|
+
export function toSafeJsonValue<T>(value: T): SafeValue<T> {
|
|
14
|
+
if (typeof value === 'bigint') {
|
|
15
|
+
const asNumber = Number(value);
|
|
16
|
+
// Use double equals instead of triple equals below to check if the BigInt
|
|
17
|
+
// is too big to fit in a JS Number.
|
|
18
|
+
// If so, then return it as a string, otherwise return it as a Number
|
|
19
|
+
// @ts-expect-error
|
|
20
|
+
return value == asNumber ? asNumber : value.toString();
|
|
21
|
+
}
|
|
22
|
+
if (value instanceof Date) {
|
|
23
|
+
return value.toISOString() as SafeValue<T>;
|
|
24
|
+
}
|
|
25
|
+
return value as SafeValue<T>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert a record to one that can be safely serialized to JSON
|
|
30
|
+
* @param record
|
|
31
|
+
*/
|
|
32
|
+
export function toSafeJsonRecord<T extends Record<string, any>>(
|
|
33
|
+
record: T
|
|
34
|
+
): SafeRecord<T> {
|
|
35
|
+
const newRecord: SafeValue<T> = {} as any;
|
|
36
|
+
for (const key in record) {
|
|
37
|
+
/* v8 ignore next 3 */
|
|
38
|
+
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
newRecord[key] = toSafeJsonValue(record[key]);
|
|
42
|
+
}
|
|
43
|
+
return newRecord;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert a recordset to one that can be safely serialized to JSON
|
|
48
|
+
* @param records
|
|
49
|
+
*/
|
|
50
|
+
export function toSafeJsonRecords<T extends Record<string, any>>(
|
|
51
|
+
records: T[]
|
|
52
|
+
): SafeRecords<T> {
|
|
53
|
+
return records.map(toSafeJsonRecord);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* JSON stringify a value or data structure, ensuring first that all values are
|
|
58
|
+
* safe to serialize
|
|
59
|
+
* @param records
|
|
60
|
+
*/
|
|
61
|
+
export function toSafeJsonString(records: Array<Record<string, any>>) {
|
|
62
|
+
return JSON.stringify(records, (_, value: any) => toSafeJsonValue(value));
|
|
63
|
+
}
|
package/vitest.config.ts
ADDED