sql-love 0.9.6 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md 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 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.6" 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.1" 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.6)](https://npmjs.com/package/sql-love)
6
- [![Dependencies](https://badgen.net/static/dependencies/0/green?v=0.9.6)](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.6)](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.6)](https://codecov.io/gh/kensnyder/sql-love)
9
- [![ISC License](https://badgen.net/static/license/ISC/green?v=0.9.6)](https://opensource.org/licenses/ISC)
5
+ [![NPM Link](https://badgen.net/npm/v/sql-love?v=1.0.1)](https://npmjs.com/package/sql-love)
6
+ [![Dependencies](https://badgen.net/static/dependencies/0/green?v=1.0.1)](https://www.npmjs.com/package/sql-love?activeTab=dependencies)
7
+ [![Build Status](https://github.com/kensnyder/sql-love/actions/workflows/node.js.yml/badge.svg?v=1.0.1)](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.1)](https://codecov.io/gh/kensnyder/sql-love)
9
+ [![ISC License](https://badgen.net/static/license/ISC/green?v=1.0.1)](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
 
@@ -64,17 +65,29 @@ if (areaCode) {
64
65
  query.sort(sortField);
65
66
  query.limit(limitTo);
66
67
  const { sql, bindings } = query.compile();
68
+ ```
69
+
70
+ Then execute the SQL in your preferred client:
67
71
 
68
- // then execute the SQL in your preferred client:
69
- // like mysql2
72
+ ```js
73
+ // mysql2:
70
74
  connection.query(sql, bindings, (err, results, fields) => {});
71
- // or Prisma
75
+
76
+ // Prisma:
72
77
  const result = await prisma.$queryRawUnsafe(sql, ...bindings);
73
78
 
74
- // Note that these are prepared statements so the values in the "bindings"
75
- // array are safe from SQL injection.
79
+ // Cloudflare d1
80
+ const { results } = await env.DB.prepare(sql)
81
+ .bind(...bindings)
82
+ .all();
76
83
  ```
77
84
 
85
+ _Note that these are prepared statements so the values in the "bindings"_
86
+ _array are safe from SQL injection, with the caveat that you are in charge of_
87
+ _quoting any identifiers. For instance, we don't recommend populating `u.email`_
88
+ _from user input in the example above. But if you do, be sure to use your_
89
+ _client's `quoteIdentifier()` function._
90
+
78
91
  It is possible to add placeholders to the base query.
79
92
 
80
93
  ```js
@@ -110,7 +123,7 @@ And "bindings" equals:
110
123
  You may specify a compiler engine to use. The default is `"mysql"`.
111
124
 
112
125
  ```js
113
- import { SelectBuilder } from 'sql-love';
126
+ import { SelectBuilder, setDefaultEngine } from 'sql-love';
114
127
 
115
128
  const query = new SelectBuilder('SELECT * FROM users')
116
129
  .limit(10)
@@ -118,19 +131,18 @@ const query = new SelectBuilder('SELECT * FROM users')
118
131
  .where('id', 1);
119
132
 
120
133
  query.compile({ engine: 'mysql' }).sql;
134
+ query.compile({ engine: 'sqlite' }).sql;
121
135
  // SELECT * FROM users WHERE id = ? OFFSET 20 LIMIT 10
122
136
 
123
137
  query.compile({ engine: 'pg' }).sql;
124
138
  // SELECT * FROM users WHERE id = $1 OFFSET 20 LIMIT 10
125
139
 
126
140
  query.compile({ engine: 'mssql' }).sql;
127
- // SELECT * FROM users WHERE id = ? OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
128
-
129
141
  query.compile({ engine: 'oracle' }).sql;
130
142
  // SELECT * FROM users WHERE id = ? OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
131
143
 
132
144
  // Or you can specify the default engine to use in the compile function
133
- SelectBuilder.setDefaultEngine('pg');
145
+ setDefaultEngine('pg');
134
146
  ```
135
147
 
136
148
  ### SQL injection
@@ -138,10 +150,19 @@ SelectBuilder.setDefaultEngine('pg');
138
150
  You should use prepared statements to run the sql and bindings returned by
139
151
  `SelectBuilder.compile()`. This will protect you from SQL injection.
140
152
 
141
- Even though `prisma.$queryRawUnsafe()` implies that the query is unsafe,
142
- execute with the "sql", and "bindings" is actually safe because it uses prepared
153
+ Using `mysqlClient.query(sql, bindings)` and
154
+ `prisma.$queryRawUnsafe(sql, ...bindings)` are examples of using prepared
143
155
  statements.
144
156
 
157
+ Note that even though the name `prisma.$queryRawUnsafe(sql, ...bindings)`
158
+ implies that the query is unsafe, it is actually safe because
159
+ `prisma.$queryRawUnsafe` uses prepared statements.
160
+
161
+ SQL Love also includes utility functions for running queries in mysql and
162
+ Prisma so you don't have to remember these functions. See documentation for
163
+ [runPrisma](#runprisma-and-runprismawithcount) and
164
+ [runMysql](#runmysql-and-runmysqlwithcount) below.
165
+
145
166
  ### Building the query
146
167
 
147
168
  Use the following methods to build queries. And see
@@ -221,7 +242,17 @@ query.where({ status: ['pending', 'approved'] });
221
242
  query.where({ 'status NOT IN': ['pending', 'approved'] });
222
243
  query.where({ 'status !=': ['pending', 'approved'] });
223
244
  query.where({ 'status <>': ['pending', 'approved'] });
224
- // Note: .having() supports the same signatures as .where()
245
+
246
+ // the following demonstrates how to use question marks for binding
247
+ query.where(
248
+ 'users.id IN (SELECT user_id FROM roles WHERE customer_id IN (?, ?))',
249
+ [1, 2]
250
+ );
251
+ query.where(
252
+ 'users.id IN (SELECT user_id FROM roles WHERE customer_id = ?)',
253
+ // even if there is only one placeholder, you must use an array
254
+ [1]
255
+ );
225
256
 
226
257
  // The following demonstrates using objects to specify multiple conditions
227
258
  query.where({
@@ -233,6 +264,8 @@ query.where({
233
264
  conditions: ['new', 'used'],
234
265
  });
235
266
 
267
+ // Note: .having() supports the same signatures as .where()
268
+
236
269
  // The following demonstrate equivalent ways to use OR
237
270
  query.orWhere([{ approved_at: null }, { denied_at: null }]);
238
271
  query.orWhere({ approved_at: null, denied_at: null });
@@ -242,6 +275,7 @@ query.orWhere([
242
275
  ['denied_at =', null],
243
276
  ]);
244
277
  query.where('(approved_at IS NULL OR denied_at IS NULL)');
278
+
245
279
  // Note: .orHaving() supports the same signatures as .orWhere()
246
280
 
247
281
  // The following demonstrate joins
@@ -451,12 +485,31 @@ const grouped = extractGrouped('dept', records);
451
485
  */
452
486
  ```
453
487
 
488
+ ### extractLookup
489
+
490
+ You can create a lookup object based on a results set
491
+
492
+ ```js
493
+ const records = [
494
+ { id: 1, name: 'John', age: 30 },
495
+ { id: 2, name: 'Jane', age: 35 },
496
+ ];
497
+
498
+ const nameById = extractLookup('id', 'name', records);
499
+ /*
500
+ {
501
+ '1': 'John',
502
+ '2': 'Jane',
503
+ }
504
+ */
505
+ ```
506
+
454
507
  ### runPrisma and runPrismaWithCount
455
508
 
456
509
  `runPrisma()` will run a query using Prisma and return the results.
457
510
 
458
511
  ```js
459
- import { SelectBuilder, runPrisma, runPrismaWithCount } from 'sql-love';
512
+ import { SelectBuilder, runPrisma } from 'sql-love';
460
513
  import { prisma } from '~/db.server';
461
514
 
462
515
  const query = new SelectBuilder('SELECT * FROM users');
@@ -469,7 +522,7 @@ const rows = runPrisma(prisma, query, { engine: 'pg' });
469
522
  found rows and pagination.
470
523
 
471
524
  ```js
472
- import { SelectBuilder, runPrisma, runPrismaWithCount } from 'sql-love';
525
+ import { SelectBuilder, runPrismaWithCount } from 'sql-love';
473
526
  import { prisma } from '~/db.server';
474
527
 
475
528
  const query = new SelectBuilder('SELECT * FROM users')
@@ -485,4 +538,74 @@ See [getPagination](#getpagination) for details on the pagination object.
485
538
 
486
539
  ### runMysql and runMysqlWithCount
487
540
 
488
- ### runPg and runPgWithCount
541
+ `runMysql()` will run a query using mysql and return the results.
542
+
543
+ ```js
544
+ import { SelectBuilder, runMysql } from 'sql-love';
545
+ import { mysql } from 'mysql2';
546
+ const client = mysql.createConnection(config);
547
+
548
+ const query = new SelectBuilder('SELECT * FROM users');
549
+ query.where('dept', 'Marketing');
550
+
551
+ const rows = await runMysql(client, query);
552
+ ```
553
+
554
+ `runMysqlWithCount()` will run a query using mysql and return the results,
555
+ found rows and pagination.
556
+
557
+ ```js
558
+ import { SelectBuilder, runMysqlWithCount } from 'sql-love';
559
+ import { mysql } from 'mysql2';
560
+ const client = mysql.createConnection(config);
561
+
562
+ const query = new SelectBuilder('SELECT * FROM users')
563
+ .where('dept', 'Marketing')
564
+ .limit(2)
565
+ .page(5);
566
+
567
+ const { records, total, pagination } = await runMysqlWithCount(client, query);
568
+ ```
569
+
570
+ You can also use the mysql2 promise api:
571
+
572
+ ```js
573
+ import { SelectBuilder, runMysqlAsync, runMysqlAsyncWithCount } from 'sql-love';
574
+ import { mysql } from 'mysql2/promise';
575
+ const client = await mysql.createConnection(config);
576
+
577
+ const query = new SelectBuilder('SELECT * FROM users')
578
+ .where('dept', 'Marketing')
579
+ .limit(2)
580
+ .page(5);
581
+
582
+ const records = await runMysqlAsync(client, query);
583
+ // OR
584
+ const { records, total, pagination } = await runMysqlAsyncWithCount(
585
+ client,
586
+ query
587
+ );
588
+ ```
589
+
590
+ ### toSafeJson
591
+
592
+ With some database clients such as Prisma, your recordsets may contain BigInt
593
+ objects. These are not safe to pass to JSON.stringify(). The following are
594
+ examples of the toSafeJson\* utility functions.
595
+
596
+ ```js
597
+ import { toSafeJsonString, toSafeJsonRecords } from 'sql-love';
598
+
599
+ const records = [{ count: 12n }, { count: 9007199254740993n }];
600
+
601
+ toSafeJsonRecords(records);
602
+ // [
603
+ // { count: 12 },
604
+ // { count: "9007199254740993" },
605
+ // ]
606
+
607
+ toSafeJsonString(records);
608
+ // '[{"count":12},{"count":"9007199254740993"}]'
609
+
610
+ // Note that BigInt values too big for Number are converted to strings
611
+ ```
package/dist/index.d.ts CHANGED
@@ -2,5 +2,8 @@ export { default as Parser } from './src/Parser/Parser';
2
2
  export { default as SelectBuilder } from './src/SelectBuilder/SelectBuilder';
3
3
  export { default as extractGrouped } from './src/extractGrouped/extractGrouped';
4
4
  export { default as extractIndexed } from './src/extractIndexed/extractIndexed';
5
+ export { default as extractLookup } from './src/extractLookup/extractLookup';
5
6
  export { default as getPagination } from './src/getPagination/getPagination';
7
+ export { runMysql, runMysqlAsync, runMysqlAsyncWithCount, runMysqlWithCount, } from './src/runMysql/runMysql';
6
8
  export { runPrisma, runPrismaWithCount } from './src/runPrisma/runPrisma';
9
+ export { toSafeJsonRecord, toSafeJsonRecords, toSafeJsonString, toSafeJsonValue, } from './src/toSafeJson/toSafeJson';
package/dist/index.js CHANGED
@@ -2,5 +2,8 @@ export { default as Parser } from './src/Parser/Parser';
2
2
  export { default as SelectBuilder } from './src/SelectBuilder/SelectBuilder';
3
3
  export { default as extractGrouped } from './src/extractGrouped/extractGrouped';
4
4
  export { default as extractIndexed } from './src/extractIndexed/extractIndexed';
5
+ export { default as extractLookup } from './src/extractLookup/extractLookup';
5
6
  export { default as getPagination } from './src/getPagination/getPagination';
7
+ export { runMysql, runMysqlAsync, runMysqlAsyncWithCount, runMysqlWithCount, } from './src/runMysql/runMysql';
6
8
  export { runPrisma, runPrismaWithCount } from './src/runPrisma/runPrisma';
9
+ export { toSafeJsonRecord, toSafeJsonRecords, toSafeJsonString, toSafeJsonValue, } from './src/toSafeJson/toSafeJson';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Given an array of records, extract an object with keyField-valueField pairs
3
+ * @param records An array of records
4
+ * @param keyField The field to use as the key
5
+ * @param valueField The field to use as the value
6
+ */
7
+ export default function extractLookup<T extends Array<Record<K | V, any>>, K extends keyof T[number], V extends keyof T[number]>(keyField: K, valueField: V, records: T): {
8
+ [key in T[number][K]]: T[number][V];
9
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Given an array of records, extract an object with keyField-valueField pairs
3
+ * @param records An array of records
4
+ * @param keyField The field to use as the key
5
+ * @param valueField The field to use as the value
6
+ */
7
+ export default function extractLookup(keyField, valueField, records) {
8
+ const lookup = {};
9
+ let idx = 0;
10
+ for (const record of records) {
11
+ if (!(keyField in record)) {
12
+ throw new Error(`Column "${String(keyField)}" is not present in record ${idx}`);
13
+ }
14
+ if (!(valueField in record)) {
15
+ throw new Error(`Column "${String(valueField)}" is not present in record ${idx}`);
16
+ }
17
+ idx++;
18
+ lookup[String(record[keyField])] = record[valueField];
19
+ }
20
+ return lookup;
21
+ }
@@ -5,13 +5,28 @@
5
5
  */
6
6
  export default function getPagination(query, total) {
7
7
  total = Number(total) || 0;
8
- const 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.6",
3
+ "version": "1.0.1",
4
4
  "description": "Build SQL queries with JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -32,11 +32,11 @@
32
32
  },
33
33
  "homepage": "https://github.com/kensnyder/sql-love#readme",
34
34
  "devDependencies": {
35
- "@vitest/coverage-v8": "0.34.6",
36
- "prettier": "3.1.0",
35
+ "@vitest/coverage-v8": "1.3.1",
36
+ "prettier": "3.2.5",
37
37
  "prettier-plugin-organize-imports": "3.2.4",
38
38
  "rimraf": "5.0.5",
39
- "typescript": "5.3.2",
40
- "vitest": "0.34.6"
39
+ "typescript": "5.3.3",
40
+ "vitest": "1.3.1"
41
41
  }
42
42
  }
@@ -0,0 +1,38 @@
1
+ import extractLookup from './extractLookup';
2
+
3
+ describe('extractLookup()', () => {
4
+ it('should handle lookup by number', () => {
5
+ const records = [
6
+ { id: 1, name: 'John', age: 30 },
7
+ { id: 2, name: 'Jane', age: 35 },
8
+ ];
9
+ const nameById = extractLookup('id', 'name', records);
10
+ expect(nameById).toEqual({ '1': 'John', '2': 'Jane' });
11
+ });
12
+ it('should handle lookups with non-unique fields', () => {
13
+ const records = [
14
+ { id: 1, name: 'John', age: 30 },
15
+ { id: 2, name: 'Jane', age: 30 },
16
+ ];
17
+ const ageById = extractLookup('age', 'name', records);
18
+ expect(ageById).toEqual({ '30': 'Jane' });
19
+ });
20
+ it('should throw if key field is not present', () => {
21
+ const records = [
22
+ { id: 1, name: 'John', age: 30 },
23
+ { id: 2, name: 'Jane' },
24
+ ];
25
+ // @ts-expect-error
26
+ const thrower = () => extractLookup('age', 'name', records);
27
+ expect(thrower).toThrow();
28
+ });
29
+ it('should throw if value field is not present', () => {
30
+ const records = [
31
+ { id: 1, name: 'John', age: 30 },
32
+ { id: 2, name: 'Jane' },
33
+ ];
34
+ // @ts-expect-error
35
+ const thrower = () => extractLookup('id', 'age', records);
36
+ expect(thrower).toThrow();
37
+ });
38
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Given an array of records, extract an object with keyField-valueField pairs
3
+ * @param records An array of records
4
+ * @param keyField The field to use as the key
5
+ * @param valueField The field to use as the value
6
+ */
7
+ export default function extractLookup<
8
+ T extends Array<Record<K | V, any>>,
9
+ K extends keyof T[number],
10
+ V extends keyof T[number],
11
+ >(
12
+ keyField: K,
13
+ valueField: V,
14
+ records: T
15
+ ): { [key in T[number][K]]: T[number][V] } {
16
+ const lookup: { [key in T[number][K]]: T[number][V] } = {} as any;
17
+ let idx = 0;
18
+ for (const record of records) {
19
+ if (!(keyField in record)) {
20
+ throw new Error(
21
+ `Column "${String(keyField)}" is not present in record ${idx}`
22
+ );
23
+ }
24
+ if (!(valueField in record)) {
25
+ throw new Error(
26
+ `Column "${String(valueField)}" is not present in record ${idx}`
27
+ );
28
+ }
29
+ idx++;
30
+ lookup[String(record[keyField])] = record[valueField];
31
+ }
32
+ return lookup;
33
+ }
@@ -17,6 +17,20 @@ describe('getPagination()', () => {
17
17
  });
18
18
  });
19
19
  it('should handle 42 results with offset', () => {
20
+ const query = new SelectBuilder('SELECT * FROM users LIMIT 10').offset(20);
21
+ const pagination = getPagination(query, 42n);
22
+ expect(pagination).toEqual({
23
+ page: 3,
24
+ prevPage: 2,
25
+ nextPage: 4,
26
+ perPage: 10,
27
+ numPages: 5,
28
+ total: 42,
29
+ isFirst: false,
30
+ isLast: false,
31
+ });
32
+ });
33
+ it('should handle 42 results with page', () => {
20
34
  const query = new SelectBuilder('SELECT * FROM users LIMIT 10').page(3);
21
35
  const pagination = getPagination(query, 42n);
22
36
  expect(pagination).toEqual({
@@ -34,13 +48,13 @@ describe('getPagination()', () => {
34
48
  const query = new SelectBuilder('SELECT * FROM users LIMIT 10');
35
49
  const pagination = getPagination(query, 0);
36
50
  expect(pagination).toEqual({
37
- page: 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,29 +10,64 @@ interface PrismaClient {
9
10
  ) => Promise<Array<Record<string, any>>>;
10
11
  }
11
12
 
12
- export async function runPrisma(
13
+ /**
14
+ * Run the given query with prisma and return the results
15
+ * @param prisma The prisma client instance
16
+ * @param query The SelectBuilder query to run
17
+ * @param options
18
+ * @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
19
+ */
20
+ export async function runPrisma<
21
+ T extends Array<Record<string, any>> = Array<Record<string, any>>,
22
+ >(
13
23
  prisma: PrismaClient,
14
24
  query: SelectBuilder,
15
25
  { engine = undefined }: { engine?: EngineStyle } = {}
16
- ): Promise<Array<Record<string, any>>> {
26
+ ): Promise<T> {
17
27
  const { sql, bindings } = query.compile({ engine });
18
- return prisma.$queryRawUnsafe(sql, ...bindings);
28
+ return prisma.$queryRawUnsafe(sql, ...bindings) as unknown as Promise<T>;
19
29
  }
20
30
 
21
- export async function runPrismaWithCount(
31
+ /**
32
+ * Run the given query with prisma and return the results, count, and pagination
33
+ * @param prisma The prisma client instance
34
+ * @param query The SelectBuilder query to run
35
+ * @param options
36
+ * @param options.engine The engine style to use ("mssql" | "mysql" | "oracle" | "pg")
37
+ * @param options.countExpr The SQL expression to use for the count (default "*")
38
+ */
39
+ export async function runPrismaWithCount<
40
+ T extends Array<Record<string, any>> = Array<Record<string, any>>,
41
+ >(
22
42
  prisma: PrismaClient,
23
43
  query: SelectBuilder,
24
44
  {
25
45
  engine = undefined,
26
46
  countExpr = '*',
27
47
  }: { engine?: EngineStyle; countExpr?: string } = {}
28
- ) {
48
+ ): Promise<{
49
+ records: T;
50
+ total: number;
51
+ pagination: {
52
+ page: number;
53
+ prevPage: number;
54
+ nextPage: number;
55
+ perPage: number;
56
+ numPages: number;
57
+ total: number;
58
+ isFirst: boolean;
59
+ isLast: boolean;
60
+ };
61
+ }> {
29
62
  const { sql, bindings } = query.compile({ engine });
30
- const records = await prisma.$queryRawUnsafe(sql, ...bindings);
63
+ const records = (await prisma.$queryRawUnsafe(
64
+ sql,
65
+ ...bindings
66
+ )) as unknown as T;
31
67
  if (records.length > 0) {
32
68
  const { sql, bindings } = query.compileCount({ engine, countExpr });
33
69
  const result = await prisma.$queryRawUnsafe(sql, ...bindings);
34
- const total = result[0].found_rows;
70
+ const total = toSafeJsonValue(result[0].found_rows);
35
71
  return { records, total, pagination: getPagination(query, total) };
36
72
  } else {
37
73
  return { records, total: 0, pagination: getPagination(query, 0) };
@@ -1,9 +1,14 @@
1
+ /**
2
+ * Count the number of times a substring appears in a string
3
+ * @param haystack
4
+ * @param needle
5
+ */
1
6
  export default function substrCount(haystack: string, needle: string) {
2
- 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,28 @@
1
+ import { toSafeJsonRecords, toSafeJsonString } from './toSafeJson';
2
+
3
+ describe('toSafeJsonString()', () => {
4
+ it('should convert small BigInt to Number', () => {
5
+ const records = [{ found_rows: 30n }];
6
+ const json = toSafeJsonString(records);
7
+ expect(json).toBe('[{"found_rows":30}]');
8
+ });
9
+ it('should convert large BigInt to String', () => {
10
+ const myBigInt = BigInt(String(Number.MAX_SAFE_INTEGER) + '0');
11
+ const records = [{ found_rows: myBigInt }];
12
+ const json = toSafeJsonString(records);
13
+ expect(json).toBe(`[{"found_rows":"${myBigInt.toString()}"}]`);
14
+ });
15
+ });
16
+ describe('jsonSafePrisma()', () => {
17
+ it('should convert small BigInt to Number', () => {
18
+ const records = [{ found_rows: 30n }];
19
+ const json = toSafeJsonRecords(records);
20
+ expect(json).toStrictEqual([{ found_rows: 30 }]);
21
+ });
22
+ it('should convert large BigInt to String', () => {
23
+ const myBigInt = BigInt(String(Number.MAX_SAFE_INTEGER) + '0');
24
+ const records = [{ found_rows: myBigInt }];
25
+ const json = toSafeJsonRecords(records);
26
+ expect(json).toStrictEqual([{ found_rows: myBigInt.toString() }]);
27
+ });
28
+ });
@@ -0,0 +1,56 @@
1
+ type SafeValue<T> = T extends BigInt ? number | string : T;
2
+ type SafeRecord<T> = Record<string, SafeValue<T>>;
3
+ type SafeRecords<T> = Array<SafeRecord<T>>;
4
+
5
+ /**
6
+ * Convert a value to a value that can be safely serialized to JSON
7
+ * @param value
8
+ */
9
+ export function toSafeJsonValue<T>(value: T): SafeValue<T> {
10
+ if (typeof value === 'bigint') {
11
+ const asNumber = Number(value);
12
+ // Use double equals instead of triple equals below to check if the BigInt
13
+ // is too big to fit in a JS Number.
14
+ // If so, then return it as a string, otherwise return it as a Number
15
+ // @ts-expect-error
16
+ return value == asNumber ? asNumber : value.toString();
17
+ }
18
+ return value as SafeValue<T>;
19
+ }
20
+
21
+ /**
22
+ * Convert a record to one that can be safely serialized to JSON
23
+ * @param record
24
+ */
25
+ export function toSafeJsonRecord<T extends Record<string, any>>(
26
+ record: T
27
+ ): SafeRecord<T> {
28
+ const newRecord: SafeValue<T> = {} as any;
29
+ for (const key in record) {
30
+ /* v8 ignore next 3 */
31
+ if (!Object.prototype.hasOwnProperty.call(record, key)) {
32
+ continue;
33
+ }
34
+ newRecord[key] = toSafeJsonValue(record[key]);
35
+ }
36
+ return newRecord;
37
+ }
38
+
39
+ /**
40
+ * Convert a recordset to one that can be safely serialized to JSON
41
+ * @param records
42
+ */
43
+ export function toSafeJsonRecords<T extends Record<string, any>>(
44
+ records: T[]
45
+ ): SafeRecords<T> {
46
+ return records.map(toSafeJsonRecord);
47
+ }
48
+
49
+ /**
50
+ * JSON stringify a value or data structure, ensuring first that all values are
51
+ * safe to serialize
52
+ * @param records
53
+ */
54
+ export function toSafeJsonString(records: Array<Record<string, any>>) {
55
+ return JSON.stringify(records, (_, value: any) => toSafeJsonValue(value));
56
+ }
@@ -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
- });