@jordanalec/dtk 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/dist/add.js +1 -0
- package/package.json +2 -1
- package/templates/init/GUIDE.md +70 -0
- package/templates/init/README.md +1 -1
- package/templates/plugins/sql/env.txt +1 -0
- package/templates/plugins/sql/example.ts +49 -0
- package/templates/plugins/sql/plugin.json +40 -0
- package/templates/plugins/sql/service.test.ts +224 -0
- package/templates/plugins/sql/service.ts +87 -0
- package/templates/plugins/sql/types.ts +10 -0
package/README.md
CHANGED
|
@@ -369,6 +369,76 @@ await suite()
|
|
|
369
369
|
|
|
370
370
|
---
|
|
371
371
|
|
|
372
|
+
### sql
|
|
373
|
+
|
|
374
|
+
Runs raw SQL queries, executes statements, calls stored procedures, and runs transactions against PostgreSQL, MySQL, or MSSQL via knex.
|
|
375
|
+
|
|
376
|
+
```bash
|
|
377
|
+
dtk add sql
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Env vars appended to `.env.template`:
|
|
381
|
+
|
|
382
|
+
```
|
|
383
|
+
SQL_CONNECTION_STRING=
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
Because the sql service holds an open connection pool, it must always be disconnected after use. Create the service outside the suite and call `disconnect()` in a `finally` block so it is guaranteed to run even when a step fails:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import "../load-env.js";
|
|
390
|
+
import { suite } from "../suite.js";
|
|
391
|
+
import { createSqlService } from "../services/sql.js";
|
|
392
|
+
|
|
393
|
+
const sql = createSqlService({
|
|
394
|
+
client: 'pg', // or 'mysql2' or 'mssql'
|
|
395
|
+
connection: process.env.SQL_CONNECTION_STRING!,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
try {
|
|
399
|
+
await suite()
|
|
400
|
+
.step("query", async () => {
|
|
401
|
+
const rows = await sql.query<{ id: number; name: string }>(
|
|
402
|
+
"SELECT id, name FROM users WHERE active = ?",
|
|
403
|
+
[true]
|
|
404
|
+
);
|
|
405
|
+
console.log(rows);
|
|
406
|
+
return rows;
|
|
407
|
+
})
|
|
408
|
+
.step("insert", async () => {
|
|
409
|
+
const affected = await sql.execute(
|
|
410
|
+
"INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
|
|
411
|
+
["Alice", "alice@example.com", true]
|
|
412
|
+
);
|
|
413
|
+
console.log("rows inserted:", affected);
|
|
414
|
+
return affected;
|
|
415
|
+
})
|
|
416
|
+
.step("transaction", async () => {
|
|
417
|
+
await sql.transaction(async (ops) => {
|
|
418
|
+
await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
|
|
419
|
+
await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
|
|
420
|
+
});
|
|
421
|
+
})
|
|
422
|
+
.run("stopOnError");
|
|
423
|
+
} finally {
|
|
424
|
+
await sql.disconnect();
|
|
425
|
+
}
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Available methods on the sql service:
|
|
429
|
+
|
|
430
|
+
| Method | Description |
|
|
431
|
+
|---|---|
|
|
432
|
+
| `query<T>(sql, params?)` | Runs a SELECT and returns the result rows as `T[]` |
|
|
433
|
+
| `execute(sql, params?)` | Runs an INSERT / UPDATE / DELETE and returns the affected row count |
|
|
434
|
+
| `callProc<T>(name, params?)` | Calls a stored procedure and returns result rows as `T[]` |
|
|
435
|
+
| `transaction(fn)` | Runs `fn` inside a transaction; rolls back automatically on error |
|
|
436
|
+
| `disconnect()` | Destroys the connection pool -- always call this in a `finally` block |
|
|
437
|
+
|
|
438
|
+
Supported clients: `pg` (PostgreSQL), `mysql2` (MySQL / MariaDB), `mssql` (SQL Server).
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
372
442
|
## Writing runbooks
|
|
373
443
|
|
|
374
444
|
A runbook is a TypeScript file that uses the `suite()` builder to chain steps and run them in sequence.
|
|
@@ -780,4 +850,5 @@ templates/
|
|
|
780
850
|
aws-s3/
|
|
781
851
|
open-ai/
|
|
782
852
|
redis/
|
|
853
|
+
sql/
|
|
783
854
|
```
|
package/dist/add.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jordanalec/dtk",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "CLI scaffolding tool for generating self-contained TypeScript runbook projects",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"axios": "^1.6.0",
|
|
46
46
|
"dotenv": "^16.4.0",
|
|
47
47
|
"jest": "^30.3.0",
|
|
48
|
+
"knex": "^3.1.0",
|
|
48
49
|
"redis": "^4.7.1",
|
|
49
50
|
"ts-jest": "^29.4.9",
|
|
50
51
|
"tsx": "^4.0.0",
|
package/templates/init/GUIDE.md
CHANGED
|
@@ -461,6 +461,76 @@ await suite()
|
|
|
461
461
|
|
|
462
462
|
---
|
|
463
463
|
|
|
464
|
+
### sql
|
|
465
|
+
|
|
466
|
+
Runs raw SQL queries, executes statements, calls stored procedures, and runs transactions against PostgreSQL, MySQL, or MSSQL via knex.
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
dtk add sql
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Required env vars:
|
|
473
|
+
|
|
474
|
+
```
|
|
475
|
+
SQL_CONNECTION_STRING=
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Because the sql service holds an open connection pool, it must always be disconnected after use. Create the service outside the suite and call `disconnect()` in a `finally` block so it is guaranteed to run even when a step fails:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
import "../load-env.js";
|
|
482
|
+
import { suite } from "../suite.js";
|
|
483
|
+
import { createSqlService } from "../services/sql.js";
|
|
484
|
+
|
|
485
|
+
const sql = createSqlService({
|
|
486
|
+
client: 'pg', // or 'mysql2' or 'mssql'
|
|
487
|
+
connection: process.env.SQL_CONNECTION_STRING!,
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await suite()
|
|
492
|
+
.step("query", async () => {
|
|
493
|
+
const rows = await sql.query<{ id: number; name: string }>(
|
|
494
|
+
"SELECT id, name FROM users WHERE active = ?",
|
|
495
|
+
[true]
|
|
496
|
+
);
|
|
497
|
+
console.log(rows);
|
|
498
|
+
return rows;
|
|
499
|
+
})
|
|
500
|
+
.step("insert", async () => {
|
|
501
|
+
const affected = await sql.execute(
|
|
502
|
+
"INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
|
|
503
|
+
["Alice", "alice@example.com", true]
|
|
504
|
+
);
|
|
505
|
+
console.log("rows inserted:", affected);
|
|
506
|
+
return affected;
|
|
507
|
+
})
|
|
508
|
+
.step("transaction", async () => {
|
|
509
|
+
await sql.transaction(async (ops) => {
|
|
510
|
+
await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
|
|
511
|
+
await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
|
|
512
|
+
});
|
|
513
|
+
})
|
|
514
|
+
.run("stopOnError");
|
|
515
|
+
} finally {
|
|
516
|
+
await sql.disconnect();
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
Available methods on the sql service:
|
|
521
|
+
|
|
522
|
+
| Method | Description |
|
|
523
|
+
|---|---|
|
|
524
|
+
| `query<T>(sql, params?)` | Runs a SELECT and returns the result rows as `T[]` |
|
|
525
|
+
| `execute(sql, params?)` | Runs an INSERT / UPDATE / DELETE and returns the affected row count |
|
|
526
|
+
| `callProc<T>(name, params?)` | Calls a stored procedure and returns result rows as `T[]` |
|
|
527
|
+
| `transaction(fn)` | Runs `fn` inside a transaction; rolls back automatically on error |
|
|
528
|
+
| `disconnect()` | Destroys the connection pool -- always call this in a `finally` block |
|
|
529
|
+
|
|
530
|
+
Supported clients: `pg` (PostgreSQL), `mysql2` (MySQL / MariaDB), `mssql` (SQL Server).
|
|
531
|
+
|
|
532
|
+
---
|
|
533
|
+
|
|
464
534
|
## Writing a custom service
|
|
465
535
|
|
|
466
536
|
If there is no plugin for the service you need, wire one in manually. Four files are involved.
|
package/templates/init/README.md
CHANGED
|
@@ -37,7 +37,7 @@ Install the dtk CLI if you haven't already, then run from this directory:
|
|
|
37
37
|
dtk add <plugin>
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
Available plugins: `aws-sqs`, `aws-sns`, `aws-dynamo`, `aws-s3`, `open-ai`
|
|
40
|
+
Available plugins: `aws-sqs`, `aws-sns`, `aws-dynamo`, `aws-s3`, `open-ai`, `redis`, `sql`
|
|
41
41
|
|
|
42
42
|
Each plugin adds a service, wires it into the suite, appends env vars to `.env.template`, creates an example runbook, and installs dependencies automatically.
|
|
43
43
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SQL_CONNECTION_STRING=
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import "../load-env.js";
|
|
2
|
+
import { suite } from "../suite.js";
|
|
3
|
+
import { createSqlService } from "../services/sql.js";
|
|
4
|
+
|
|
5
|
+
type User = { id: number; name: string; email: string; };
|
|
6
|
+
type TransferResult = { status: string; };
|
|
7
|
+
|
|
8
|
+
const sql = createSqlService({
|
|
9
|
+
client: 'pg', // change to 'mysql2' or 'mssql' as needed
|
|
10
|
+
connection: process.env.SQL_CONNECTION_STRING!,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
await suite()
|
|
15
|
+
.step("query-users", async () => {
|
|
16
|
+
const users = await sql.query<User>(
|
|
17
|
+
"SELECT id, name, email FROM users WHERE active = ?",
|
|
18
|
+
[true]
|
|
19
|
+
);
|
|
20
|
+
console.log("active users:", users);
|
|
21
|
+
return users;
|
|
22
|
+
})
|
|
23
|
+
.step("insert-user", async () => {
|
|
24
|
+
const affected = await sql.execute(
|
|
25
|
+
"INSERT INTO users (name, email, active) VALUES (?, ?, ?)",
|
|
26
|
+
["Alice", "alice@example.com", true]
|
|
27
|
+
);
|
|
28
|
+
console.log("rows inserted:", affected);
|
|
29
|
+
return affected;
|
|
30
|
+
})
|
|
31
|
+
.step("call-stored-proc", async () => {
|
|
32
|
+
const result = await sql.callProc<TransferResult>(
|
|
33
|
+
"usp_activate_user",
|
|
34
|
+
[42]
|
|
35
|
+
);
|
|
36
|
+
console.log("proc result:", result);
|
|
37
|
+
return result;
|
|
38
|
+
})
|
|
39
|
+
.step("transfer-in-transaction", async () => {
|
|
40
|
+
await sql.transaction(async (ops) => {
|
|
41
|
+
await ops.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?", [100, 1]);
|
|
42
|
+
await ops.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?", [100, 2]);
|
|
43
|
+
});
|
|
44
|
+
console.log("transfer complete");
|
|
45
|
+
})
|
|
46
|
+
.run("stopOnError");
|
|
47
|
+
} finally {
|
|
48
|
+
await sql.disconnect();
|
|
49
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sql",
|
|
3
|
+
"description": "SQL -- query, execute, callProc, transaction across pg / mysql2 / mssql",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"knex": "^3.1.0",
|
|
6
|
+
"pg": "^8.13.0",
|
|
7
|
+
"mysql2": "^3.11.0",
|
|
8
|
+
"mssql": "^11.0.1"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
{ "src": "service.ts", "dest": "src/services/sql.ts" },
|
|
12
|
+
{ "src": "types.ts", "dest": "src/types/sql.ts" },
|
|
13
|
+
{ "src": "service.test.ts", "dest": "src/services/sql.test.ts" }
|
|
14
|
+
],
|
|
15
|
+
"env": "env.txt",
|
|
16
|
+
"example": "example.ts",
|
|
17
|
+
"transforms": {
|
|
18
|
+
"service.ts": [
|
|
19
|
+
{ "from": "./types.js", "to": "../types/sql.js" }
|
|
20
|
+
],
|
|
21
|
+
"service.test.ts": [
|
|
22
|
+
{ "from": "./service.js", "to": "./sql.js" }
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"patches": {
|
|
26
|
+
"src/suite.ts": {
|
|
27
|
+
"imports": [
|
|
28
|
+
"import { createSqlService } from \"./services/sql.js\";",
|
|
29
|
+
"import type { SqlConfig } from \"./types/sql.js\";"
|
|
30
|
+
],
|
|
31
|
+
"configs": " private sqlConfig?: SqlConfig;",
|
|
32
|
+
"methods": " sql(config: SqlConfig): this { this.sqlConfig = config; return this; }",
|
|
33
|
+
"services": " sql: createSqlService(this.sqlConfig),"
|
|
34
|
+
},
|
|
35
|
+
"src/types/suite.ts": {
|
|
36
|
+
"type-imports": "import type { SqlOps } from \"./sql.js\";",
|
|
37
|
+
"service-types": " sql: { query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>; execute(sql: string, params?: unknown[]): Promise<number>; callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]>; transaction<T>(fn: (ops: SqlOps) => Promise<T>): Promise<T>; disconnect(): Promise<void>; };"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createSqlService } from './service.js';
|
|
2
|
+
|
|
3
|
+
jest.mock('knex');
|
|
4
|
+
import knexLib from 'knex';
|
|
5
|
+
|
|
6
|
+
const mockTrxRaw = jest.fn();
|
|
7
|
+
const mockTrx = { raw: mockTrxRaw };
|
|
8
|
+
|
|
9
|
+
const mockRaw = jest.fn();
|
|
10
|
+
const mockDestroy = jest.fn().mockResolvedValue(undefined);
|
|
11
|
+
const mockTransaction = jest.fn();
|
|
12
|
+
|
|
13
|
+
const mockDb = {
|
|
14
|
+
raw: mockRaw,
|
|
15
|
+
destroy: mockDestroy,
|
|
16
|
+
transaction: mockTransaction,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
jest.clearAllMocks();
|
|
21
|
+
(knexLib as unknown as jest.Mock).mockReturnValue(mockDb);
|
|
22
|
+
mockDestroy.mockResolvedValue(undefined);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('createSqlService', () => {
|
|
26
|
+
describe('configuration', () => {
|
|
27
|
+
it('throws when any method is called without config', async () => {
|
|
28
|
+
const sql = createSqlService();
|
|
29
|
+
await expect(sql.query('SELECT 1')).rejects.toThrow('sql service is not configured');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('creates a knex instance with the provided client and connection', async () => {
|
|
33
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
34
|
+
const sql = createSqlService({ client: 'pg', connection: 'postgresql://localhost/test' });
|
|
35
|
+
await sql.query('SELECT 1');
|
|
36
|
+
expect(knexLib).toHaveBeenCalledWith({ client: 'pg', connection: 'postgresql://localhost/test' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reuses the same knex instance across multiple calls', async () => {
|
|
40
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
41
|
+
const sql = createSqlService({ client: 'pg', connection: 'postgresql://localhost/test' });
|
|
42
|
+
await sql.query('SELECT 1');
|
|
43
|
+
await sql.query('SELECT 2');
|
|
44
|
+
expect(knexLib).toHaveBeenCalledTimes(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('query', () => {
|
|
49
|
+
it('returns result.rows for pg', async () => {
|
|
50
|
+
mockRaw.mockResolvedValue({ rows: [{ id: 1 }] });
|
|
51
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
52
|
+
const result = await sql.query('SELECT * FROM users');
|
|
53
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns result[0] for mysql2', async () => {
|
|
57
|
+
mockRaw.mockResolvedValue([[{ id: 1 }], []]);
|
|
58
|
+
const sql = createSqlService({ client: 'mysql2', connection: '' });
|
|
59
|
+
const result = await sql.query('SELECT * FROM users');
|
|
60
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns result[0] for mssql', async () => {
|
|
64
|
+
mockRaw.mockResolvedValue([[{ id: 1 }]]);
|
|
65
|
+
const sql = createSqlService({ client: 'mssql', connection: '' });
|
|
66
|
+
const result = await sql.query('SELECT * FROM users');
|
|
67
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('passes sql and params to raw', async () => {
|
|
71
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
72
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
73
|
+
await sql.query('SELECT * FROM users WHERE id = ?', [42]);
|
|
74
|
+
expect(mockRaw).toHaveBeenCalledWith('SELECT * FROM users WHERE id = ?', [42]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('defaults params to empty array when omitted', async () => {
|
|
78
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
79
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
80
|
+
await sql.query('SELECT 1');
|
|
81
|
+
expect(mockRaw).toHaveBeenCalledWith('SELECT 1', []);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('execute', () => {
|
|
86
|
+
it('returns rowCount for pg', async () => {
|
|
87
|
+
mockRaw.mockResolvedValue({ rowCount: 3 });
|
|
88
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
89
|
+
const result = await sql.execute('DELETE FROM users WHERE active = ?', [false]);
|
|
90
|
+
expect(result).toBe(3);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns affectedRows for mysql2', async () => {
|
|
94
|
+
mockRaw.mockResolvedValue([{ affectedRows: 2 }]);
|
|
95
|
+
const sql = createSqlService({ client: 'mysql2', connection: '' });
|
|
96
|
+
const result = await sql.execute('UPDATE users SET active = ?', [true]);
|
|
97
|
+
expect(result).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns rowsAffected[0] for mssql', async () => {
|
|
101
|
+
mockRaw.mockResolvedValue({ rowsAffected: [5] });
|
|
102
|
+
const sql = createSqlService({ client: 'mssql', connection: '' });
|
|
103
|
+
const result = await sql.execute('INSERT INTO logs (msg) VALUES (?)', ['test']);
|
|
104
|
+
expect(result).toBe(5);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns 0 when pg rowCount is null', async () => {
|
|
108
|
+
mockRaw.mockResolvedValue({ rowCount: null });
|
|
109
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
110
|
+
const result = await sql.execute('SELECT 1');
|
|
111
|
+
expect(result).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('callProc', () => {
|
|
116
|
+
it('uses CALL syntax for pg', async () => {
|
|
117
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
118
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
119
|
+
await sql.callProc('my_proc', [1, 'test']);
|
|
120
|
+
expect(mockRaw).toHaveBeenCalledWith('CALL my_proc(?, ?)', [1, 'test']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('uses CALL syntax for mysql2', async () => {
|
|
124
|
+
mockRaw.mockResolvedValue([[[]], {}]);
|
|
125
|
+
const sql = createSqlService({ client: 'mysql2', connection: '' });
|
|
126
|
+
await sql.callProc('my_proc', [1]);
|
|
127
|
+
expect(mockRaw).toHaveBeenCalledWith('CALL my_proc(?)', [1]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('uses EXEC syntax for mssql', async () => {
|
|
131
|
+
mockRaw.mockResolvedValue([[{ result: 1 }]]);
|
|
132
|
+
const sql = createSqlService({ client: 'mssql', connection: '' });
|
|
133
|
+
await sql.callProc('usp_my_proc', [1, 2]);
|
|
134
|
+
expect(mockRaw).toHaveBeenCalledWith('EXEC usp_my_proc ?, ?', [1, 2]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('uses EXEC without params for mssql when no params given', async () => {
|
|
138
|
+
mockRaw.mockResolvedValue([[{ result: 1 }]]);
|
|
139
|
+
const sql = createSqlService({ client: 'mssql', connection: '' });
|
|
140
|
+
await sql.callProc('usp_my_proc');
|
|
141
|
+
expect(mockRaw).toHaveBeenCalledWith('EXEC usp_my_proc', []);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns result.rows for pg', async () => {
|
|
145
|
+
mockRaw.mockResolvedValue({ rows: [{ status: 'ok' }] });
|
|
146
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
147
|
+
const result = await sql.callProc('my_proc', []);
|
|
148
|
+
expect(result).toEqual([{ status: 'ok' }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns result[0] for mssql', async () => {
|
|
152
|
+
mockRaw.mockResolvedValue([[{ status: 'ok' }]]);
|
|
153
|
+
const sql = createSqlService({ client: 'mssql', connection: '' });
|
|
154
|
+
const result = await sql.callProc('usp_my_proc', []);
|
|
155
|
+
expect(result).toEqual([{ status: 'ok' }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns the first result set for mysql2', async () => {
|
|
159
|
+
mockRaw.mockResolvedValue([[[{ status: 'ok' }], {}], []]);
|
|
160
|
+
const sql = createSqlService({ client: 'mysql2', connection: '' });
|
|
161
|
+
const result = await sql.callProc('my_proc', []);
|
|
162
|
+
expect(result).toEqual([{ status: 'ok' }]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('transaction', () => {
|
|
167
|
+
it('calls knex transaction and passes SqlOps to fn', async () => {
|
|
168
|
+
mockTrxRaw.mockResolvedValue({ rows: [{ id: 1 }] });
|
|
169
|
+
mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
|
|
170
|
+
|
|
171
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
172
|
+
const result = await sql.transaction(async (ops) => ops.query('SELECT 1'));
|
|
173
|
+
|
|
174
|
+
expect(mockTransaction).toHaveBeenCalledTimes(1);
|
|
175
|
+
expect(result).toEqual([{ id: 1 }]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('ops.execute within transaction uses the transaction executor', async () => {
|
|
179
|
+
mockTrxRaw.mockResolvedValue({ rowCount: 2 });
|
|
180
|
+
mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
|
|
181
|
+
|
|
182
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
183
|
+
const affected = await sql.transaction(async (ops) => ops.execute('DELETE FROM tmp'));
|
|
184
|
+
|
|
185
|
+
expect(mockTrxRaw).toHaveBeenCalledWith('DELETE FROM tmp', []);
|
|
186
|
+
expect(affected).toBe(2);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('ops.callProc within transaction uses the transaction executor', async () => {
|
|
190
|
+
mockTrxRaw.mockResolvedValue({ rows: [] });
|
|
191
|
+
mockTransaction.mockImplementation((fn: (trx: typeof mockTrx) => Promise<unknown>) => fn(mockTrx));
|
|
192
|
+
|
|
193
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
194
|
+
await sql.transaction(async (ops) => ops.callProc('my_proc', [1]));
|
|
195
|
+
|
|
196
|
+
expect(mockTrxRaw).toHaveBeenCalledWith('CALL my_proc(?)', [1]);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('disconnect', () => {
|
|
201
|
+
it('destroys the knex instance and clears the reference', async () => {
|
|
202
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
203
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
204
|
+
await sql.query('SELECT 1');
|
|
205
|
+
await sql.disconnect();
|
|
206
|
+
expect(mockDestroy).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('is a no-op when never connected', async () => {
|
|
210
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
211
|
+
await expect(sql.disconnect()).resolves.toBeUndefined();
|
|
212
|
+
expect(mockDestroy).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('creates a new knex instance after reconnecting following disconnect', async () => {
|
|
216
|
+
mockRaw.mockResolvedValue({ rows: [] });
|
|
217
|
+
const sql = createSqlService({ client: 'pg', connection: '' });
|
|
218
|
+
await sql.query('SELECT 1');
|
|
219
|
+
await sql.disconnect();
|
|
220
|
+
await sql.query('SELECT 1');
|
|
221
|
+
expect(knexLib).toHaveBeenCalledTimes(2);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import knex, { Knex } from 'knex';
|
|
2
|
+
import type { SqlConfig, SqlOps } from './types.js';
|
|
3
|
+
|
|
4
|
+
export function createSqlService(config?: SqlConfig) {
|
|
5
|
+
const ensureConfig = () => {
|
|
6
|
+
if (!config) throw new Error("sql service is not configured -- call .sql(config) on the suite");
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let db: Knex | null = null;
|
|
10
|
+
|
|
11
|
+
const getDb = (): Knex => {
|
|
12
|
+
ensureConfig();
|
|
13
|
+
if (!db) {
|
|
14
|
+
db = knex({ client: config!.client, connection: config!.connection });
|
|
15
|
+
}
|
|
16
|
+
return db;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const extractRows = <T>(result: unknown): T[] => {
|
|
20
|
+
if (config!.client === 'pg') return (result as { rows: T[] }).rows;
|
|
21
|
+
return ((result as unknown[])[0] as T[]) ?? [];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const extractAffected = (result: unknown): number => {
|
|
25
|
+
if (config!.client === 'pg') return (result as { rowCount: number }).rowCount ?? 0;
|
|
26
|
+
if (config!.client === 'mssql') return (result as { rowsAffected: number[] }).rowsAffected?.[0] ?? 0;
|
|
27
|
+
return ((result as [{ affectedRows: number }])[0]?.affectedRows) ?? 0;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const buildCallSql = (name: string, params: unknown[]): string => {
|
|
31
|
+
const placeholders = params.map(() => '?').join(', ');
|
|
32
|
+
return config!.client === 'mssql'
|
|
33
|
+
? `EXEC ${name}${params.length ? ` ${placeholders}` : ''}`
|
|
34
|
+
: `CALL ${name}(${placeholders})`;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const extractProcRows = <T>(result: unknown): T[] => {
|
|
38
|
+
if (config!.client === 'pg') return (result as { rows: T[] }).rows;
|
|
39
|
+
if (config!.client === 'mssql') return ((result as unknown[])[0] as T[]) ?? [];
|
|
40
|
+
// mysql2 procs return [[resultSet, OkPacket], fields] -- first element of resultSet is the rows
|
|
41
|
+
const first = ((result as unknown[][])[0])?.[0];
|
|
42
|
+
return (Array.isArray(first) ? first : (result as unknown[])[0]) as T[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const bind = (params?: unknown[]) => (params ?? []) as any[];
|
|
46
|
+
|
|
47
|
+
const makeOps = (executor: Knex | Knex.Transaction): SqlOps => ({
|
|
48
|
+
async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
49
|
+
const result = await executor.raw(sql, bind(params));
|
|
50
|
+
return extractRows<T>(result);
|
|
51
|
+
},
|
|
52
|
+
async execute(sql: string, params?: unknown[]): Promise<number> {
|
|
53
|
+
const result = await executor.raw(sql, bind(params));
|
|
54
|
+
return extractAffected(result);
|
|
55
|
+
},
|
|
56
|
+
async callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]> {
|
|
57
|
+
const bound = bind(params);
|
|
58
|
+
const result = await executor.raw(buildCallSql(name, bound), bound);
|
|
59
|
+
return extractProcRows<T>(result);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
async query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
65
|
+
const result = await getDb().raw(sql, bind(params));
|
|
66
|
+
return extractRows<T>(result);
|
|
67
|
+
},
|
|
68
|
+
async execute(sql: string, params?: unknown[]): Promise<number> {
|
|
69
|
+
const result = await getDb().raw(sql, bind(params));
|
|
70
|
+
return extractAffected(result);
|
|
71
|
+
},
|
|
72
|
+
async callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]> {
|
|
73
|
+
const bound = bind(params);
|
|
74
|
+
const result = await getDb().raw(buildCallSql(name, bound), bound);
|
|
75
|
+
return extractProcRows<T>(result);
|
|
76
|
+
},
|
|
77
|
+
async transaction<T>(fn: (ops: SqlOps) => Promise<T>): Promise<T> {
|
|
78
|
+
return getDb().transaction(trx => fn(makeOps(trx)));
|
|
79
|
+
},
|
|
80
|
+
async disconnect(): Promise<void> {
|
|
81
|
+
if (db) {
|
|
82
|
+
await db.destroy();
|
|
83
|
+
db = null;
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SqlConfig {
|
|
2
|
+
client: 'pg' | 'mysql2' | 'mssql';
|
|
3
|
+
connection: string | Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SqlOps {
|
|
7
|
+
query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
8
|
+
execute(sql: string, params?: unknown[]): Promise<number>;
|
|
9
|
+
callProc<T = Record<string, unknown>>(name: string, params?: unknown[]): Promise<T[]>;
|
|
10
|
+
}
|