sql-love 0.9.5 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/LICENSE.md +1 -1
  3. package/README.md +116 -18
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.js +3 -0
  6. package/dist/src/SelectBuilder/SelectBuilder.d.ts +4 -4
  7. package/dist/src/SelectBuilder/SelectBuilder.js +11 -7
  8. package/dist/src/extractLookup/extractLookup.d.ts +9 -0
  9. package/dist/src/extractLookup/extractLookup.js +21 -0
  10. package/dist/src/getPagination/getPagination.js +21 -6
  11. package/dist/src/runMysql/runMysql.d.ts +12 -0
  12. package/dist/src/runMysql/runMysql.js +12 -0
  13. package/dist/src/runPrisma/runPrisma.d.ts +15 -0
  14. package/dist/src/runPrisma/runPrisma.js +17 -1
  15. package/dist/src/substrCount/substrCount.d.ts +5 -0
  16. package/dist/src/substrCount/substrCount.js +5 -0
  17. package/dist/src/toSafeJson/toSafeJson.d.ts +25 -0
  18. package/dist/src/toSafeJson/toSafeJson.js +48 -0
  19. package/index.ts +13 -0
  20. package/package.json +5 -5
  21. package/src/SelectBuilder/SelectBuilder.spec.ts +3 -3
  22. package/src/SelectBuilder/SelectBuilder.ts +11 -7
  23. package/src/extractLookup/extractLookup.spec.ts +38 -0
  24. package/src/extractLookup/extractLookup.ts +33 -0
  25. package/src/getPagination/getPagination.spec.ts +19 -5
  26. package/src/getPagination/getPagination.ts +18 -6
  27. package/src/runMysql/runMysql.spec.ts +129 -7
  28. package/src/runMysql/runMysql.ts +12 -0
  29. package/src/runPrisma/runPrisma.spec.ts +32 -0
  30. package/src/runPrisma/runPrisma.ts +17 -1
  31. package/src/substrCount/substrCount.ts +12 -7
  32. package/src/toSafeJson/toSafeJson.spec.ts +40 -0
  33. package/src/toSafeJson/toSafeJson.ts +63 -0
  34. package/vitest.config.ts +11 -0
  35. package/vitest.config.js +0 -8
package/CHANGELOG.md CHANGED
@@ -1,10 +1,23 @@
1
1
  ## Change Log
2
2
 
3
- ### v0.9.5 - 5 Dec 2023
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
+
15
+ ### v0.9.6 - 5 Dec 2023
4
16
 
5
17
  - Utility functions (extractGrouped, extractIndexed, getPagination, runPrisma, runMysql)
6
18
  - Added `SelectBuilder.setDefaultEngine(engine)`
7
19
  - More documentation
20
+ - Fix `.sortField()` logic
8
21
 
9
22
  ### v0.9.4 - 30 Nov 2023
10
23
 
package/LICENSE.md CHANGED
@@ -1,5 +1,5 @@
1
1
  ISC License (ISC)
2
- Copyright 2021-2023 Ken Snyder
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.9.5" width="250" height="208" />
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
- [![NPM Link](https://badgen.net/npm/v/sql-love?v=0.9.5)](https://npmjs.com/package/sql-love)
6
- [![Dependencies](https://badgen.net/static/dependencies/0/green?v=0.9.5)](https://npmjs.com/package/sql-love)
7
- [![Build Status](https://github.com/kensnyder/sql-love/actions/workflows/node.js.yml/badge.svg?v=0.9.5)](https://github.com/kensnyder/sql-love/actions)
8
- [![Code Coverage](https://codecov.io/gh/kensnyder/sql-love/branch/main/graph/badge.svg?v=0.9.5)](https://codecov.io/gh/kensnyder/sql-love)
9
- [![ISC License](https://badgen.net/static/license/ISC/green?v=0.9.5)](https://opensource.org/licenses/ISC)
5
+ [![NPM Link](https://badgen.net/npm/v/sql-love?v=1.0.0)](https://npmjs.com/package/sql-love)
6
+ [![Dependencies](https://badgen.net/static/dependencies/0/green?v=1.0.0)](https://npmjs.com/package/sql-love)
7
+ [![Build Status](https://github.com/kensnyder/sql-love/actions/workflows/node.js.yml/badge.svg?v=1.0.0)](https://github.com/kensnyder/sql-love/actions)
8
+ [![Code Coverage](https://codecov.io/gh/kensnyder/sql-love/branch/main/graph/badge.svg?v=1.0.0)](https://codecov.io/gh/kensnyder/sql-love)
9
+ [![ISC License](https://badgen.net/static/license/ISC/green?v=1.0.0)](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](#where-examples)
25
+ - [More examples](#more-examples)
26
26
  - [Counting results](#counting-results)
27
27
  - [Other methods](#other-methods)
28
- - [new SelectBuilder() limitations](#selectparse-limitations)
28
+ - [Parser limitations](#parser-limitations)
29
29
  - [Utility functions](#utility-functions)
30
30
  - [getPagination](#getpagination)
31
- - [extractGrouped](#extractGrouped)
32
- - [extractIndexed](#extractIndexed)
33
- - [runMysql and runPrismaWithCount](#runPrisma-and-runPrismaWithCount)
34
- - [runMysql and runMysqlWithCount](#runMysql-and-runMysqlWithCount)
35
- - [runPg and runPgWithCount](#runPg-and-runPgWithCount)
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
- Even though `prisma.$queryRawUnsafe()` implies that the query is unsafe,
142
- execute with the "sql", and "bindings" is actually safe because it uses prepared
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, runPrismaWithCount } from 'sql-love';
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, runPrisma, runPrismaWithCount } from 'sql-love';
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
- ### runPg and runPgWithCount
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';
@@ -342,13 +342,13 @@ export default class SelectBuilder {
342
342
  orderBy(column: string, direction?: 'ASC' | 'DESC'): this;
343
343
  /**
344
344
  * Sort by the given column, with a map of columns to translate
345
- * @param column The column name such as "created_at" or "-created_at" for descending
346
- * @param [mapNames] Column names to translate from one name to another
345
+ * @param fieldName The file name such as "created_at" or "-created_at" for descending
346
+ * @param [mapNames] Column names to translate from field to column expression
347
347
  * @example
348
348
  * query.sortField('-modified_at'); // ORDER BY modified_at DESC
349
- * query.sortField('created_at', {'created_at': 'created'}); // ORDER BY created
349
+ * query.sortField('-created', {'created': 'created_at'}); // ORDER BY created_at DESC
350
350
  */
351
- sortField(column: string, mapNames?: Record<string, string>): this;
351
+ sortField(fieldName: string, mapNames?: Record<string, string>): this;
352
352
  /**
353
353
  * Check to see if the given string is 0 or all digits not starting with 0
354
354
  * @param string The input string
@@ -716,16 +716,20 @@ export default class SelectBuilder {
716
716
  }
717
717
  /**
718
718
  * Sort by the given column, with a map of columns to translate
719
- * @param column The column name such as "created_at" or "-created_at" for descending
720
- * @param [mapNames] Column names to translate from one name to another
719
+ * @param fieldName The file name such as "created_at" or "-created_at" for descending
720
+ * @param [mapNames] Column names to translate from field to column expression
721
721
  * @example
722
722
  * query.sortField('-modified_at'); // ORDER BY modified_at DESC
723
- * query.sortField('created_at', {'created_at': 'created'}); // ORDER BY created
723
+ * query.sortField('-created', {'created': 'created_at'}); // ORDER BY created_at DESC
724
724
  */
725
- sortField(column, mapNames = {}) {
726
- column.replace(/^-(.+)/, '$1 DESC');
727
- column = mapNames[column] || column;
728
- this.orderBy(column);
725
+ sortField(fieldName, mapNames = {}) {
726
+ let direction = 'ASC';
727
+ if (fieldName.startsWith('-')) {
728
+ direction = 'DESC';
729
+ fieldName = fieldName.slice(1);
730
+ }
731
+ const column = mapNames[fieldName] || fieldName;
732
+ this.orderBy(column, direction);
729
733
  return this;
730
734
  }
731
735
  /**
@@ -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 page = Number(query._page) || 1;
9
- const perPage = Number(query._limit);
10
- const numPages = Math.ceil(total / perPage) || 0;
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 {
@@ -1 +1,6 @@
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): number;
@@ -1,3 +1,8 @@
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, needle) {
2
7
  let count = 0;
3
8
  let pos = haystack.indexOf(needle);
@@ -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.9.5",
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.34.6",
36
- "prettier": "3.1.0",
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.2",
40
- "vitest": "0.34.6"
39
+ "typescript": "5.3.3",
40
+ "vitest": "1.0.4"
41
41
  }
42
42
  }
@@ -450,7 +450,7 @@ describe('sortField()', () => {
450
450
  const query = new SelectBuilder('SELECT * FROM posts');
451
451
  query.sortField('posts.created_at');
452
452
  const { sql, bindings } = query.compile();
453
- expect(trim(sql)).toBe('SELECT * FROM posts ORDER BY posts.created_at');
453
+ expect(trim(sql)).toBe('SELECT * FROM posts ORDER BY posts.created_at ASC');
454
454
  expect(bindings).toEqual([]);
455
455
  });
456
456
  it('should handle minus signs', () => {
@@ -464,12 +464,12 @@ describe('sortField()', () => {
464
464
  });
465
465
  it('should handle mapping', () => {
466
466
  const query = new SelectBuilder('SELECT * FROM posts');
467
- query.sortField('created_at', {
467
+ query.sortField('-created_at', {
468
468
  created_at: 'posts.created_timestamp',
469
469
  });
470
470
  const { sql, bindings } = query.compile();
471
471
  expect(trim(sql)).toBe(
472
- 'SELECT * FROM posts ORDER BY posts.created_timestamp'
472
+ 'SELECT * FROM posts ORDER BY posts.created_timestamp DESC'
473
473
  );
474
474
  expect(bindings).toEqual([]);
475
475
  });
@@ -809,16 +809,20 @@ export default class SelectBuilder {
809
809
 
810
810
  /**
811
811
  * Sort by the given column, with a map of columns to translate
812
- * @param column The column name such as "created_at" or "-created_at" for descending
813
- * @param [mapNames] Column names to translate from one name to another
812
+ * @param fieldName The file name such as "created_at" or "-created_at" for descending
813
+ * @param [mapNames] Column names to translate from field to column expression
814
814
  * @example
815
815
  * query.sortField('-modified_at'); // ORDER BY modified_at DESC
816
- * query.sortField('created_at', {'created_at': 'created'}); // ORDER BY created
816
+ * query.sortField('-created', {'created': 'created_at'}); // ORDER BY created_at DESC
817
817
  */
818
- sortField(column: string, mapNames: Record<string, string> = {}) {
819
- column.replace(/^-(.+)/, '$1 DESC');
820
- column = mapNames[column] || column;
821
- this.orderBy(column);
818
+ sortField(fieldName: string, mapNames: Record<string, string> = {}) {
819
+ let direction = 'ASC' as 'ASC' | 'DESC';
820
+ if (fieldName.startsWith('-')) {
821
+ direction = 'DESC';
822
+ fieldName = fieldName.slice(1);
823
+ }
824
+ const column = mapNames[fieldName] || fieldName;
825
+ this.orderBy(column, direction);
822
826
  return this;
823
827
  }
824
828
 
@@ -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: 1,
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: true,
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: 1,
65
+ page: null,
52
66
  prevPage: null,
53
67
  nextPage: null,
54
- perPage: NaN,
68
+ perPage: null,
55
69
  numPages: 0,
56
70
  total: 42,
57
- isFirst: true,
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 page = Number(query._page) || 1;
14
- const perPage = Number(query._limit);
15
- const numPages = Math.ceil(total / perPage) || 0;
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 also run with count', async () => {
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 invoke client.query with correct args (async)', async () => {
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 also run with count (async)', async () => {
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(client.query).toHaveBeenCalledWith(
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
  });
@@ -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
- let count = 0;
3
- let pos = haystack.indexOf(needle);
4
- while (pos !== -1) {
5
- count += 1;
6
- pos = haystack.indexOf(needle, pos + 1);
7
- }
8
- return count;
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
+ }
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['src/**/*.spec.ts'],
6
+ coverage: {
7
+ exclude: ['index.ts'],
8
+ },
9
+ globals: true,
10
+ },
11
+ });
package/vitest.config.js DELETED
@@ -1,8 +0,0 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- export default defineConfig({
4
- test: {
5
- include: ['**/*.spec.ts'],
6
- globals: true,
7
- },
8
- });