@tylix/orm 0.5.0 → 0.5.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/package.json +2 -2
- package/src/adapters/DatabaseError.d.ts +52 -0
- package/src/adapters/DatabaseError.js +336 -0
- package/src/adapters/MongoAdapter.js +124 -77
- package/src/adapters/MysqlAdapter.js +41 -31
- package/src/adapters/PostgresAdapter.js +38 -18
- package/src/adapters/SqliteAdapter.js +33 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tylix/orm",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "node --test src/**/*.test.js"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"@tylix/shared": "^0.5.
|
|
19
|
+
"@tylix/shared": "^0.5.1",
|
|
20
20
|
"mongodb": "^6.5.0",
|
|
21
21
|
"mysql2": "^3.23.1",
|
|
22
22
|
"pg": "^8.11.0"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type DatabaseDriver = 'mysql' | 'postgres' | 'sqlite' | 'mongodb';
|
|
2
|
+
|
|
3
|
+
export type DatabaseErrorKind =
|
|
4
|
+
| 'connection_failed'
|
|
5
|
+
| 'auth_failed'
|
|
6
|
+
| 'unknown_database'
|
|
7
|
+
| 'missing_table'
|
|
8
|
+
| 'missing_column'
|
|
9
|
+
| 'duplicate_key'
|
|
10
|
+
| 'not_null'
|
|
11
|
+
| 'foreign_key'
|
|
12
|
+
| 'data_too_long'
|
|
13
|
+
| 'syntax'
|
|
14
|
+
| 'unsupported_query'
|
|
15
|
+
| 'driver_missing'
|
|
16
|
+
| 'unknown';
|
|
17
|
+
|
|
18
|
+
export interface DatabaseErrorOptions {
|
|
19
|
+
driver?: DatabaseDriver | null;
|
|
20
|
+
kind?: DatabaseErrorKind;
|
|
21
|
+
title?: string | null;
|
|
22
|
+
hint?: string | null;
|
|
23
|
+
code?: string | number | null;
|
|
24
|
+
detail?: string | null;
|
|
25
|
+
table?: string | null;
|
|
26
|
+
column?: string | null;
|
|
27
|
+
sql?: string | null;
|
|
28
|
+
params?: unknown[];
|
|
29
|
+
cause?: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export declare class DatabaseError extends Error {
|
|
33
|
+
constructor(message: string, options?: DatabaseErrorOptions);
|
|
34
|
+
driver: DatabaseDriver | null;
|
|
35
|
+
kind: DatabaseErrorKind;
|
|
36
|
+
title: string | null;
|
|
37
|
+
hint: string | null;
|
|
38
|
+
code: string | number | null;
|
|
39
|
+
detail: string | null;
|
|
40
|
+
table: string | null;
|
|
41
|
+
column: string | null;
|
|
42
|
+
sql: string | null;
|
|
43
|
+
params: unknown[];
|
|
44
|
+
cause: unknown;
|
|
45
|
+
toJSON(): Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export declare function normalizeDatabaseError(
|
|
49
|
+
driver: DatabaseDriver,
|
|
50
|
+
err: unknown,
|
|
51
|
+
context?: { sql?: string | null; params?: unknown[] },
|
|
52
|
+
): DatabaseError;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
const DRIVER_LABELS = {
|
|
2
|
+
mysql: 'MySQL',
|
|
3
|
+
postgres: 'PostgreSQL',
|
|
4
|
+
sqlite: 'SQLite',
|
|
5
|
+
mongodb: 'MongoDB',
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thrown by every database adapter instead of the raw driver error.
|
|
10
|
+
* Same shape for MySQL, PostgreSQL, SQLite and MongoDB, so the server
|
|
11
|
+
* and the debug page never need to know which driver is in use.
|
|
12
|
+
*/
|
|
13
|
+
export class DatabaseError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
message,
|
|
16
|
+
{
|
|
17
|
+
driver = null,
|
|
18
|
+
kind = 'unknown',
|
|
19
|
+
title = null,
|
|
20
|
+
hint = null,
|
|
21
|
+
code = null,
|
|
22
|
+
detail = null,
|
|
23
|
+
table = null,
|
|
24
|
+
column = null,
|
|
25
|
+
sql = null,
|
|
26
|
+
params = [],
|
|
27
|
+
cause = null,
|
|
28
|
+
} = {},
|
|
29
|
+
) {
|
|
30
|
+
super(message)
|
|
31
|
+
this.name = 'DatabaseError'
|
|
32
|
+
this.driver = driver
|
|
33
|
+
this.kind = kind
|
|
34
|
+
this.title = title
|
|
35
|
+
this.hint = hint
|
|
36
|
+
this.code = code
|
|
37
|
+
this.detail = detail
|
|
38
|
+
this.table = table
|
|
39
|
+
this.column = column
|
|
40
|
+
this.sql = sql
|
|
41
|
+
this.params = params
|
|
42
|
+
this.cause = cause
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Safe-to-serialize view used by the debug response. The stack is
|
|
46
|
+
// added by the server, only when debug mode is on.
|
|
47
|
+
toJSON() {
|
|
48
|
+
return {
|
|
49
|
+
name: this.name,
|
|
50
|
+
driver: this.driver,
|
|
51
|
+
kind: this.kind,
|
|
52
|
+
title: this.title,
|
|
53
|
+
message: this.message,
|
|
54
|
+
hint: this.hint,
|
|
55
|
+
code: this.code,
|
|
56
|
+
detail: this.detail,
|
|
57
|
+
table: this.table,
|
|
58
|
+
column: this.column,
|
|
59
|
+
sql: this.sql,
|
|
60
|
+
params: (this.params ?? []).map(safeParam),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function safeParam(value) {
|
|
66
|
+
if (typeof value === 'bigint') return value.toString()
|
|
67
|
+
if (value instanceof Date) return value.toISOString()
|
|
68
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {
|
|
69
|
+
return `<${value.length} bytes>`
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === 'string' && value.length > 200) {
|
|
72
|
+
return `${value.slice(0, 200)}… (${value.length} chars)`
|
|
73
|
+
}
|
|
74
|
+
return value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------
|
|
78
|
+
// What each kind means, in plain language, and how to fix it.
|
|
79
|
+
// ---------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
const KIND_INFO = {
|
|
82
|
+
connection_failed: ({ driver }) => ({
|
|
83
|
+
title: `Cannot connect to ${DRIVER_LABELS[driver] ?? 'the database'}`,
|
|
84
|
+
hint: 'Make sure the database server is running, and that the host, port and file/URL in your .env are correct.',
|
|
85
|
+
}),
|
|
86
|
+
auth_failed: () => ({
|
|
87
|
+
title: 'Database login was rejected',
|
|
88
|
+
hint: 'The username or password is wrong. Check DATABASE_USER and DATABASE_PASSWORD in your .env.',
|
|
89
|
+
}),
|
|
90
|
+
unknown_database: () => ({
|
|
91
|
+
title: 'The database does not exist',
|
|
92
|
+
hint: 'Create the database first, or fix DATABASE_NAME in your .env.',
|
|
93
|
+
}),
|
|
94
|
+
missing_table: ({ table }) => ({
|
|
95
|
+
title: table ? `Table "${table}" does not exist` : 'Table does not exist',
|
|
96
|
+
hint: 'The migration that creates this table has not run yet. Run `npx tylix migrate`, or check the table name for typos.',
|
|
97
|
+
}),
|
|
98
|
+
missing_column: ({ column, table }) => ({
|
|
99
|
+
title: column
|
|
100
|
+
? `Column "${column}" does not exist${table ? ` on "${table}"` : ''}`
|
|
101
|
+
: 'Column does not exist',
|
|
102
|
+
hint: 'Either a migration adding this column has not run (try `npx tylix migrate`), or the column name is misspelled in your model or query.',
|
|
103
|
+
}),
|
|
104
|
+
duplicate_key: ({ detail }) => ({
|
|
105
|
+
title: 'A unique rule was violated',
|
|
106
|
+
hint: `${detail ? `${detail}. ` : ''}A row with this value already exists. Check the existing data, or validate for duplicates before inserting.`,
|
|
107
|
+
}),
|
|
108
|
+
not_null: ({ column }) => ({
|
|
109
|
+
title: column ? `"${column}" cannot be empty` : 'A required value is missing',
|
|
110
|
+
hint: 'This column is NOT NULL and no value was given. Make sure the form or payload sends it, or give the column a default in the migration.',
|
|
111
|
+
}),
|
|
112
|
+
foreign_key: () => ({
|
|
113
|
+
title: 'A relationship (foreign key) rule was violated',
|
|
114
|
+
hint: 'Either you referenced a row that does not exist, or you are deleting a row that other rows still point to.',
|
|
115
|
+
}),
|
|
116
|
+
data_too_long: ({ column }) => ({
|
|
117
|
+
title: column ? `Value is too long for "${column}"` : 'Value is too long for its column',
|
|
118
|
+
hint: 'Shorten the value, or change the column to a larger type (for example `text` instead of `string`).',
|
|
119
|
+
}),
|
|
120
|
+
syntax: () => ({
|
|
121
|
+
title: 'The SQL has a syntax error',
|
|
122
|
+
hint: 'Look at the SQL below. The database stopped at the spot it quotes in the message.',
|
|
123
|
+
}),
|
|
124
|
+
unsupported_query: () => ({
|
|
125
|
+
title: 'This adapter cannot run that query',
|
|
126
|
+
hint: 'MongoAdapter only understands a limited set of simple query shapes. Use the model query builder, or extend parseQuery().',
|
|
127
|
+
}),
|
|
128
|
+
driver_missing: () => ({
|
|
129
|
+
title: 'SQLite support is not available',
|
|
130
|
+
hint: 'SQLite uses the built-in node:sqlite module, which needs Node.js 22.5 or newer. Upgrade Node, or pick another database.',
|
|
131
|
+
}),
|
|
132
|
+
unknown: ({ driver }) => ({
|
|
133
|
+
title: `${DRIVER_LABELS[driver] ?? 'Database'} error`,
|
|
134
|
+
hint: null,
|
|
135
|
+
}),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------
|
|
139
|
+
// Per-driver classifiers. Each returns { kind, table, column, detail }.
|
|
140
|
+
// ---------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
const lastSegment = (name) => (name ? name.split('.').pop() : null)
|
|
143
|
+
|
|
144
|
+
function splitQualified(name) {
|
|
145
|
+
if (!name) return { table: null, column: null }
|
|
146
|
+
const parts = name.split('.')
|
|
147
|
+
if (parts.length < 2) return { table: null, column: parts[0] }
|
|
148
|
+
return { table: parts[parts.length - 2], column: parts[parts.length - 1] }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const MYSQL_KINDS = {
|
|
152
|
+
ER_NO_SUCH_TABLE: 'missing_table',
|
|
153
|
+
ER_BAD_TABLE_ERROR: 'missing_table',
|
|
154
|
+
ER_BAD_FIELD_ERROR: 'missing_column',
|
|
155
|
+
ER_DUP_ENTRY: 'duplicate_key',
|
|
156
|
+
ER_BAD_NULL_ERROR: 'not_null',
|
|
157
|
+
ER_NO_DEFAULT_FOR_FIELD: 'not_null',
|
|
158
|
+
ER_NO_REFERENCED_ROW_2: 'foreign_key',
|
|
159
|
+
ER_ROW_IS_REFERENCED_2: 'foreign_key',
|
|
160
|
+
ER_PARSE_ERROR: 'syntax',
|
|
161
|
+
ER_ACCESS_DENIED_ERROR: 'auth_failed',
|
|
162
|
+
ER_BAD_DB_ERROR: 'unknown_database',
|
|
163
|
+
ER_DATA_TOO_LONG: 'data_too_long',
|
|
164
|
+
ECONNREFUSED: 'connection_failed',
|
|
165
|
+
ENOTFOUND: 'connection_failed',
|
|
166
|
+
ETIMEDOUT: 'connection_failed',
|
|
167
|
+
ECONNRESET: 'connection_failed',
|
|
168
|
+
PROTOCOL_CONNECTION_LOST: 'connection_failed',
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function classifyMysql(err, message) {
|
|
172
|
+
const kind = MYSQL_KINDS[err.code] ?? 'unknown'
|
|
173
|
+
let table = null
|
|
174
|
+
let column = null
|
|
175
|
+
let detail = null
|
|
176
|
+
let m
|
|
177
|
+
|
|
178
|
+
if ((m = /Table '([^']+)' doesn't exist/.exec(message))) {
|
|
179
|
+
table = lastSegment(m[1])
|
|
180
|
+
}
|
|
181
|
+
m =
|
|
182
|
+
/Unknown column '([^']+)'/.exec(message) ||
|
|
183
|
+
/Column '([^']+)' cannot be null/.exec(message) ||
|
|
184
|
+
/Field '([^']+)' doesn't have a default value/.exec(message) ||
|
|
185
|
+
/Data too long for column '([^']+)'/.exec(message)
|
|
186
|
+
if (m) column = m[1]
|
|
187
|
+
if ((m = /Duplicate entry '(.*)' for key '(.*)'/.exec(message))) {
|
|
188
|
+
detail = `Value "${m[1]}" already exists for key ${m[2]}`
|
|
189
|
+
}
|
|
190
|
+
return { kind, table, column, detail }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const PG_KINDS = {
|
|
194
|
+
'42P01': 'missing_table',
|
|
195
|
+
'42703': 'missing_column',
|
|
196
|
+
'23505': 'duplicate_key',
|
|
197
|
+
'23502': 'not_null',
|
|
198
|
+
'23503': 'foreign_key',
|
|
199
|
+
'42601': 'syntax',
|
|
200
|
+
'22001': 'data_too_long',
|
|
201
|
+
'28P01': 'auth_failed',
|
|
202
|
+
'28000': 'auth_failed',
|
|
203
|
+
'3D000': 'unknown_database',
|
|
204
|
+
ECONNREFUSED: 'connection_failed',
|
|
205
|
+
ENOTFOUND: 'connection_failed',
|
|
206
|
+
ETIMEDOUT: 'connection_failed',
|
|
207
|
+
ECONNRESET: 'connection_failed',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function classifyPostgres(err, message) {
|
|
211
|
+
const kind = PG_KINDS[err.code] ?? 'unknown'
|
|
212
|
+
let table = err.table ?? null
|
|
213
|
+
let column = err.column ?? null
|
|
214
|
+
let m
|
|
215
|
+
|
|
216
|
+
if (!table && (m = /relation "([^"]+)" does not exist/.exec(message))) {
|
|
217
|
+
table = lastSegment(m[1])
|
|
218
|
+
}
|
|
219
|
+
if (!table && (m = /of relation "([^"]+)"/.exec(message))) table = m[1]
|
|
220
|
+
if (!column && (m = /column "([^"]+)"/.exec(message))) column = m[1]
|
|
221
|
+
|
|
222
|
+
return { kind, table, column, detail: err.detail ?? null }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function classifySqlite(err, message) {
|
|
226
|
+
let m
|
|
227
|
+
|
|
228
|
+
if (/node:sqlite/.test(message)) {
|
|
229
|
+
return { kind: 'driver_missing', table: null, column: null, detail: null }
|
|
230
|
+
}
|
|
231
|
+
if ((m = /no such table: (\S+)/.exec(message))) {
|
|
232
|
+
return { kind: 'missing_table', table: lastSegment(m[1]), column: null, detail: null }
|
|
233
|
+
}
|
|
234
|
+
if ((m = /table (\S+) has no column named (\S+)/.exec(message))) {
|
|
235
|
+
return { kind: 'missing_column', table: m[1], column: m[2], detail: null }
|
|
236
|
+
}
|
|
237
|
+
if ((m = /no such column: (\S+)/.exec(message))) {
|
|
238
|
+
return { kind: 'missing_column', table: null, column: m[1], detail: null }
|
|
239
|
+
}
|
|
240
|
+
if ((m = /UNIQUE constraint failed: (.+)/.exec(message))) {
|
|
241
|
+
const { table, column } = splitQualified(m[1].split(',')[0].trim())
|
|
242
|
+
return { kind: 'duplicate_key', table, column, detail: `Unique rule on ${m[1]}` }
|
|
243
|
+
}
|
|
244
|
+
if ((m = /NOT NULL constraint failed: (\S+)/.exec(message))) {
|
|
245
|
+
const { table, column } = splitQualified(m[1])
|
|
246
|
+
return { kind: 'not_null', table, column, detail: null }
|
|
247
|
+
}
|
|
248
|
+
if (/FOREIGN KEY constraint failed/.test(message)) {
|
|
249
|
+
return { kind: 'foreign_key', table: null, column: null, detail: null }
|
|
250
|
+
}
|
|
251
|
+
if (/syntax error/i.test(message)) {
|
|
252
|
+
return { kind: 'syntax', table: null, column: null, detail: null }
|
|
253
|
+
}
|
|
254
|
+
if (/unable to open database file/i.test(message)) {
|
|
255
|
+
return { kind: 'connection_failed', table: null, column: null, detail: null }
|
|
256
|
+
}
|
|
257
|
+
return { kind: 'unknown', table: null, column: null, detail: null }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function classifyMongo(err, message) {
|
|
261
|
+
const none = { table: null, column: null, detail: null }
|
|
262
|
+
let m
|
|
263
|
+
|
|
264
|
+
if (err.code === 11000) {
|
|
265
|
+
const table = (m = /collection: (\S+)/.exec(message)) ? lastSegment(m[1]) : null
|
|
266
|
+
const detail = err.keyValue
|
|
267
|
+
? `Value ${JSON.stringify(err.keyValue)} already exists`
|
|
268
|
+
: null
|
|
269
|
+
return { kind: 'duplicate_key', table, column: null, detail }
|
|
270
|
+
}
|
|
271
|
+
if (
|
|
272
|
+
/^Mongo(ServerSelection|Network|NetworkTimeout|Parse)Error$/.test(err.name) ||
|
|
273
|
+
err.code === 'ECONNREFUSED' ||
|
|
274
|
+
err.code === 'ENOTFOUND'
|
|
275
|
+
) {
|
|
276
|
+
return { kind: 'connection_failed', ...none }
|
|
277
|
+
}
|
|
278
|
+
if (err.code === 18 || err.codeName === 'AuthenticationFailed') {
|
|
279
|
+
return { kind: 'auth_failed', ...none }
|
|
280
|
+
}
|
|
281
|
+
if (
|
|
282
|
+
/does not recognize this query shape|cannot handle query type|Unsupported where operator/.test(
|
|
283
|
+
message,
|
|
284
|
+
)
|
|
285
|
+
) {
|
|
286
|
+
return { kind: 'unsupported_query', ...none }
|
|
287
|
+
}
|
|
288
|
+
return { kind: 'unknown', ...none }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const CLASSIFIERS = {
|
|
292
|
+
mysql: classifyMysql,
|
|
293
|
+
postgres: classifyPostgres,
|
|
294
|
+
sqlite: classifySqlite,
|
|
295
|
+
mongodb: classifyMongo,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Turns any driver error into a DatabaseError. Adapters call this in
|
|
300
|
+
* their catch blocks. Already-normalized errors pass through untouched.
|
|
301
|
+
*/
|
|
302
|
+
export function normalizeDatabaseError(driver, err, { sql = null, params = [] } = {}) {
|
|
303
|
+
if (err instanceof DatabaseError) return err
|
|
304
|
+
|
|
305
|
+
// mysql2 can throw an AggregateError with an empty message when
|
|
306
|
+
// "localhost" resolves to several addresses and all refuse.
|
|
307
|
+
const message =
|
|
308
|
+
err?.sqlMessage || err?.message || err?.code || String(err) || 'Unknown database error'
|
|
309
|
+
|
|
310
|
+
const classify = CLASSIFIERS[driver]
|
|
311
|
+
const info = classify
|
|
312
|
+
? classify(err ?? {}, message)
|
|
313
|
+
: { kind: 'unknown', table: null, column: null, detail: null }
|
|
314
|
+
|
|
315
|
+
const { title, hint } = (KIND_INFO[info.kind] ?? KIND_INFO.unknown)({
|
|
316
|
+
driver,
|
|
317
|
+
...info,
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
const error = new DatabaseError(message, {
|
|
321
|
+
driver,
|
|
322
|
+
kind: info.kind,
|
|
323
|
+
title,
|
|
324
|
+
hint,
|
|
325
|
+
code: err?.code ?? err?.codeName ?? null,
|
|
326
|
+
detail: info.detail,
|
|
327
|
+
table: info.table,
|
|
328
|
+
column: info.column,
|
|
329
|
+
sql,
|
|
330
|
+
params,
|
|
331
|
+
cause: err,
|
|
332
|
+
})
|
|
333
|
+
// Start the stack at whoever called the adapter, not inside this factory.
|
|
334
|
+
Error.captureStackTrace?.(error, normalizeDatabaseError)
|
|
335
|
+
return error
|
|
336
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { MongoClient } from 'mongodb'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
3
4
|
|
|
4
5
|
export function parseQuery(sql) {
|
|
5
6
|
const trimmed = sql.trim().replace(/\s+/g, ' ')
|
|
@@ -26,10 +27,6 @@ export function parseQuery(sql) {
|
|
|
26
27
|
return { type: 'findOne', table: match[1], field: match[2] }
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
30
|
-
return { type: 'findOne', table: match[1], field: match[2] }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
30
|
if ((match = /^SELECT \* FROM (\w+)$/i.exec(trimmed))) {
|
|
34
31
|
return { type: 'findAll', table: match[1] }
|
|
35
32
|
}
|
|
@@ -71,9 +68,22 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
71
68
|
}
|
|
72
69
|
|
|
73
70
|
async connect() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
try {
|
|
72
|
+
// Fail after 5s instead of the driver's default 30s, so a stopped
|
|
73
|
+
// MongoDB server shows up quickly.
|
|
74
|
+
this.client = new MongoClient(this.url, { serverSelectionTimeoutMS: 5000 })
|
|
75
|
+
await this.client.connect()
|
|
76
|
+
this.db = this.client.db(this.dbName)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const error = normalizeDatabaseError('mongodb', err)
|
|
79
|
+
// Never print credentials: mongodb://user:pass@host -> mongodb://***@host
|
|
80
|
+
const safeUrl = String(this.url).replace(/\/\/[^@/]*@/, '//***@')
|
|
81
|
+
error.detail = `Tried ${safeUrl}, database "${this.dbName}"`
|
|
82
|
+
await this.client?.close().catch(() => {})
|
|
83
|
+
this.client = null
|
|
84
|
+
this.db = null
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
77
87
|
return this
|
|
78
88
|
}
|
|
79
89
|
|
|
@@ -95,6 +105,18 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
|
|
108
|
+
// Wraps any Mongo operation so failures become DatabaseErrors.
|
|
109
|
+
// `sql` is whatever text best describes what was attempted: the
|
|
110
|
+
// SQL-like string for run/get/all, or an equivalent db.x.find(...)
|
|
111
|
+
// description for query/count/paginate.
|
|
112
|
+
async guard(ctx, fn) {
|
|
113
|
+
try {
|
|
114
|
+
return await fn()
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw normalizeDatabaseError('mongodb', err, ctx)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
98
120
|
async nextId(table) {
|
|
99
121
|
const counters = this.db.collection('__tylix_counters')
|
|
100
122
|
const result = await counters.findOneAndUpdate(
|
|
@@ -107,6 +129,10 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
107
129
|
|
|
108
130
|
async run(sql, params = []) {
|
|
109
131
|
this.ensureConnected()
|
|
132
|
+
return this.guard({ sql, params }, () => this._run(sql, params))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async _run(sql, params) {
|
|
110
136
|
const query = parseQuery(sql)
|
|
111
137
|
|
|
112
138
|
if (query.type === 'createTable') {
|
|
@@ -179,100 +205,121 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
179
205
|
|
|
180
206
|
async get(sql, params = []) {
|
|
181
207
|
this.ensureConnected()
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
return this.guard({ sql, params }, async () => {
|
|
209
|
+
const query = parseQuery(sql)
|
|
210
|
+
|
|
211
|
+
if (query.type === 'findOne') {
|
|
212
|
+
const doc = await this.db
|
|
213
|
+
.collection(query.table)
|
|
214
|
+
.findOne({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
215
|
+
return doc ?? null
|
|
216
|
+
}
|
|
190
217
|
|
|
191
|
-
|
|
218
|
+
throw new Error(`get() cannot handle query type "${query.type}"`)
|
|
219
|
+
})
|
|
192
220
|
}
|
|
193
221
|
|
|
194
222
|
async all(sql, params = []) {
|
|
195
223
|
this.ensureConnected()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
224
|
+
return this.guard({ sql, params }, async () => {
|
|
225
|
+
const query = parseQuery(sql)
|
|
226
|
+
|
|
227
|
+
if (query.type === 'findAll') {
|
|
228
|
+
return this.db
|
|
229
|
+
.collection(query.table)
|
|
230
|
+
.find({}, { projection: { _id: 0 } })
|
|
231
|
+
.toArray()
|
|
232
|
+
}
|
|
204
233
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
234
|
+
if (query.type === 'findOne') {
|
|
235
|
+
return this.db
|
|
236
|
+
.collection(query.table)
|
|
237
|
+
.find({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
238
|
+
.toArray()
|
|
239
|
+
}
|
|
211
240
|
|
|
212
|
-
|
|
241
|
+
throw new Error(`all() cannot handle query type "${query.type}"`)
|
|
242
|
+
})
|
|
213
243
|
}
|
|
214
244
|
|
|
215
245
|
async count(table) {
|
|
216
246
|
this.ensureConnected()
|
|
217
|
-
return this.db
|
|
247
|
+
return this.guard({ sql: `db.${table}.countDocuments()` }, () =>
|
|
248
|
+
this.db.collection(table).countDocuments(),
|
|
249
|
+
)
|
|
218
250
|
}
|
|
219
251
|
|
|
220
252
|
async paginate(table, limit, offset) {
|
|
221
253
|
this.ensureConnected()
|
|
222
|
-
return this.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
254
|
+
return this.guard(
|
|
255
|
+
{
|
|
256
|
+
sql: `db.${table}.find({}).sort({"id":1}).skip(${offset}).limit(${limit})`,
|
|
257
|
+
},
|
|
258
|
+
() =>
|
|
259
|
+
this.db
|
|
260
|
+
.collection(table)
|
|
261
|
+
.find({}, { projection: { _id: 0 } })
|
|
262
|
+
.sort({ id: 1 })
|
|
263
|
+
.skip(offset)
|
|
264
|
+
.limit(limit)
|
|
265
|
+
.toArray(),
|
|
266
|
+
)
|
|
229
267
|
}
|
|
230
268
|
|
|
231
269
|
async listTables() {
|
|
232
270
|
this.ensureConnected()
|
|
233
|
-
|
|
234
|
-
|
|
271
|
+
return this.guard({ sql: 'db.listCollections()' }, async () => {
|
|
272
|
+
const collections = await this.db.listCollections().toArray()
|
|
273
|
+
return collections.map((c) => c.name)
|
|
274
|
+
})
|
|
235
275
|
}
|
|
236
276
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
}
|
|
277
|
+
async query(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
|
|
278
|
+
this.ensureConnected()
|
|
241
279
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
else if (w.operator === '!=') filter[w.field] = { $ne: w.value }
|
|
252
|
-
else if (w.operator === '>') filter[w.field] = { $gt: w.value }
|
|
253
|
-
else if (w.operator === '<') filter[w.field] = { $lt: w.value }
|
|
254
|
-
else if (w.operator === '>=') filter[w.field] = { $gte: w.value }
|
|
255
|
-
else if (w.operator === '<=') filter[w.field] = { $lte: w.value }
|
|
256
|
-
else
|
|
257
|
-
throw new Error(
|
|
258
|
-
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
259
|
-
)
|
|
260
|
-
}
|
|
280
|
+
const filter = {}
|
|
281
|
+
const sort = {}
|
|
282
|
+
const description = () => {
|
|
283
|
+
let text = `db.${table}.find(${JSON.stringify(filter)})`
|
|
284
|
+
if (Object.keys(sort).length) text += `.sort(${JSON.stringify(sort)})`
|
|
285
|
+
if (offsetValue !== undefined) text += `.skip(${offsetValue})`
|
|
286
|
+
if (limitValue !== undefined) text += `.limit(${limitValue})`
|
|
287
|
+
return text
|
|
288
|
+
}
|
|
261
289
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
290
|
+
return this.guard({ get sql() { return description() } }, async () => {
|
|
291
|
+
for (const w of wheres) {
|
|
292
|
+
if (w.operator === '=') filter[w.field] = w.value
|
|
293
|
+
else if (w.operator === '!=') filter[w.field] = { $ne: w.value }
|
|
294
|
+
else if (w.operator === '>') filter[w.field] = { $gt: w.value }
|
|
295
|
+
else if (w.operator === '<') filter[w.field] = { $lt: w.value }
|
|
296
|
+
else if (w.operator === '>=') filter[w.field] = { $gte: w.value }
|
|
297
|
+
else if (w.operator === '<=') filter[w.field] = { $lte: w.value }
|
|
298
|
+
else
|
|
299
|
+
throw new Error(
|
|
300
|
+
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
301
|
+
)
|
|
302
|
+
}
|
|
265
303
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
304
|
+
let cursor = this.db
|
|
305
|
+
.collection(table)
|
|
306
|
+
.find(filter, { projection: { _id: 0 } })
|
|
307
|
+
|
|
308
|
+
if (orders.length > 0) {
|
|
309
|
+
orders.forEach((o) => {
|
|
310
|
+
sort[o.field] = o.direction.toUpperCase() === 'DESC' ? -1 : 1
|
|
311
|
+
})
|
|
312
|
+
cursor = cursor.sort(sort)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (offsetValue !== undefined) cursor = cursor.skip(offsetValue)
|
|
316
|
+
if (limitValue !== undefined) cursor = cursor.limit(limitValue)
|
|
317
|
+
|
|
318
|
+
return cursor.toArray()
|
|
270
319
|
})
|
|
271
|
-
cursor = cursor.sort(sort)
|
|
272
320
|
}
|
|
273
321
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
322
|
+
columnType() {
|
|
323
|
+
return null
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
3
4
|
|
|
4
5
|
const COLUMN_TYPES = {
|
|
5
6
|
increments: 'INT AUTO_INCREMENT PRIMARY KEY',
|
|
@@ -31,19 +32,28 @@ export class MysqlAdapter extends DatabaseAdapter {
|
|
|
31
32
|
this.connection = null
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
35
|
+
async connect() {
|
|
36
|
+
this.connection = mysql.createPool({
|
|
37
|
+
...this.config,
|
|
38
|
+
waitForConnections: true,
|
|
39
|
+
connectionLimit: 10,
|
|
40
|
+
queueLimit: 0,
|
|
41
|
+
enableKeepAlive: true,
|
|
42
|
+
keepAliveInitialDelay: 10000,
|
|
43
|
+
})
|
|
44
|
+
try {
|
|
45
|
+
const conn = await this.connection.getConnection() // fail fast on bad credentials
|
|
46
|
+
conn.release()
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const error = normalizeDatabaseError('mysql', err)
|
|
49
|
+
const { host, port, user, database } = this.config
|
|
50
|
+
error.detail = `Tried ${host}:${port} as "${user}", database "${database}"`
|
|
51
|
+
await this.connection.end().catch(() => {})
|
|
52
|
+
this.connection = null
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
47
57
|
|
|
48
58
|
async close() {
|
|
49
59
|
if (this.connection) {
|
|
@@ -62,51 +72,51 @@ export class MysqlAdapter extends DatabaseAdapter {
|
|
|
62
72
|
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
|
|
75
|
+
// The one place every statement runs through, so every MySQL failure
|
|
76
|
+
// is translated into a DatabaseError that carries the SQL.
|
|
77
|
+
async execute(sql, params = []) {
|
|
66
78
|
this.ensureConnected()
|
|
67
79
|
const converted = params.map(toMysqlDateTime)
|
|
68
|
-
|
|
80
|
+
try {
|
|
81
|
+
const [result] = await this.connection.execute(sql, converted)
|
|
82
|
+
return result
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw normalizeDatabaseError('mysql', err, { sql, params: converted })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async run(sql, params = []) {
|
|
89
|
+
const result = await this.execute(sql, params)
|
|
69
90
|
return { ...result, lastInsertRowid: result.insertId }
|
|
70
91
|
}
|
|
71
92
|
|
|
72
93
|
async get(sql, params = []) {
|
|
73
|
-
this.
|
|
74
|
-
const converted = params.map(toMysqlDateTime)
|
|
75
|
-
const [rows] = await this.connection.execute(sql, converted)
|
|
94
|
+
const rows = await this.execute(sql, params)
|
|
76
95
|
return rows[0] ?? null
|
|
77
96
|
}
|
|
78
97
|
|
|
79
98
|
async all(sql, params = []) {
|
|
80
|
-
this.
|
|
81
|
-
const converted = params.map(toMysqlDateTime)
|
|
82
|
-
const [rows] = await this.connection.execute(sql, converted)
|
|
83
|
-
return rows
|
|
99
|
+
return this.execute(sql, params)
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
async count(table) {
|
|
87
|
-
this.
|
|
88
|
-
const [rows] = await this.connection.execute(
|
|
89
|
-
`SELECT COUNT(*) as count FROM ${table}`,
|
|
90
|
-
)
|
|
103
|
+
const rows = await this.execute(`SELECT COUNT(*) as count FROM ${table}`)
|
|
91
104
|
return Number(rows[0].count)
|
|
92
105
|
}
|
|
93
106
|
|
|
94
107
|
async paginate(table, limit, offset) {
|
|
95
|
-
this.ensureConnected()
|
|
96
108
|
// mysql2 does not support ? placeholders for LIMIT/OFFSET reliably
|
|
97
109
|
// across versions, so these are inlined after being forced to
|
|
98
110
|
// integers here (never from raw user input) to avoid injection.
|
|
99
111
|
const safeLimit = Number.isInteger(limit) ? limit : parseInt(limit, 10)
|
|
100
112
|
const safeOffset = Number.isInteger(offset) ? offset : parseInt(offset, 10)
|
|
101
|
-
|
|
113
|
+
return this.execute(
|
|
102
114
|
`SELECT * FROM ${table} ORDER BY id ASC LIMIT ${safeLimit} OFFSET ${safeOffset}`,
|
|
103
115
|
)
|
|
104
|
-
return rows
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
async listTables() {
|
|
108
|
-
this.
|
|
109
|
-
const [rows] = await this.connection.execute('SHOW TABLES')
|
|
119
|
+
const rows = await this.execute('SHOW TABLES')
|
|
110
120
|
return rows.map((r) => Object.values(r)[0])
|
|
111
121
|
}
|
|
112
122
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import pg from 'pg'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
3
4
|
|
|
4
5
|
const COLUMN_TYPES = {
|
|
5
6
|
increments: 'SERIAL PRIMARY KEY',
|
|
@@ -38,8 +39,24 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
38
39
|
|
|
39
40
|
async connect() {
|
|
40
41
|
this.pool = new pg.Pool(this.config)
|
|
42
|
+
try {
|
|
43
|
+
// pg connects lazily, so test it now. A wrong password or a
|
|
44
|
+
// stopped server should fail at startup, not on the first request.
|
|
45
|
+
const client = await this.pool.connect()
|
|
46
|
+
client.release()
|
|
47
|
+
} catch (err) {
|
|
48
|
+
const error = normalizeDatabaseError('postgres', err)
|
|
49
|
+
const { host, port, user, database } = this.config
|
|
50
|
+
error.detail = this.config.connectionString
|
|
51
|
+
? 'Using DATABASE_URL connection string'
|
|
52
|
+
: `Tried ${host}:${port} as "${user}", database "${database}"`
|
|
53
|
+
await this.pool.end().catch(() => {})
|
|
54
|
+
this.pool = null
|
|
55
|
+
throw error
|
|
56
|
+
}
|
|
41
57
|
return this
|
|
42
58
|
}
|
|
59
|
+
|
|
43
60
|
async close() {
|
|
44
61
|
if (this.pool) {
|
|
45
62
|
await this.pool.end()
|
|
@@ -57,39 +74,43 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
57
74
|
}
|
|
58
75
|
}
|
|
59
76
|
|
|
60
|
-
|
|
77
|
+
// The one place every statement runs through. `sql` here is the final
|
|
78
|
+
// SQL (with $1, $2 placeholders), exactly what PostgreSQL received.
|
|
79
|
+
async execute(sql, params = []) {
|
|
61
80
|
this.ensureConnected()
|
|
62
|
-
|
|
63
|
-
|
|
81
|
+
try {
|
|
82
|
+
return await this.pool.query(sql, params)
|
|
83
|
+
} catch (err) {
|
|
84
|
+
throw normalizeDatabaseError('postgres', err, { sql, params })
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async run(sql, params = []) {
|
|
89
|
+
const result = await this.execute(
|
|
90
|
+
toPositionalPlaceholders(ensureReturningId(sql)),
|
|
91
|
+
params,
|
|
92
|
+
)
|
|
64
93
|
const lastInsertRowid = result.rows[0]?.id
|
|
65
94
|
return { ...result, lastInsertRowid }
|
|
66
95
|
}
|
|
67
96
|
|
|
68
97
|
async get(sql, params = []) {
|
|
69
|
-
this.
|
|
70
|
-
const finalSql = toPositionalPlaceholders(sql)
|
|
71
|
-
const result = await this.pool.query(finalSql, params)
|
|
98
|
+
const result = await this.execute(toPositionalPlaceholders(sql), params)
|
|
72
99
|
return result.rows[0] ?? null
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
async all(sql, params = []) {
|
|
76
|
-
this.
|
|
77
|
-
const finalSql = toPositionalPlaceholders(sql)
|
|
78
|
-
const result = await this.pool.query(finalSql, params)
|
|
103
|
+
const result = await this.execute(toPositionalPlaceholders(sql), params)
|
|
79
104
|
return result.rows
|
|
80
105
|
}
|
|
81
106
|
|
|
82
107
|
async count(table) {
|
|
83
|
-
this.
|
|
84
|
-
const result = await this.pool.query(
|
|
85
|
-
`SELECT COUNT(*) as count FROM ${table}`,
|
|
86
|
-
)
|
|
108
|
+
const result = await this.execute(`SELECT COUNT(*) as count FROM ${table}`)
|
|
87
109
|
return Number(result.rows[0].count)
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
async paginate(table, limit, offset) {
|
|
91
|
-
this.
|
|
92
|
-
const result = await this.pool.query(
|
|
113
|
+
const result = await this.execute(
|
|
93
114
|
`SELECT * FROM ${table} ORDER BY id ASC LIMIT $1 OFFSET $2`,
|
|
94
115
|
[limit, offset],
|
|
95
116
|
)
|
|
@@ -97,8 +118,7 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
97
118
|
}
|
|
98
119
|
|
|
99
120
|
async listTables() {
|
|
100
|
-
this.
|
|
101
|
-
const result = await this.pool.query(
|
|
121
|
+
const result = await this.execute(
|
|
102
122
|
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
|
|
103
123
|
)
|
|
104
124
|
return result.rows.map((r) => r.tablename)
|
|
@@ -113,4 +133,4 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
113
133
|
}
|
|
114
134
|
return sql
|
|
115
135
|
}
|
|
116
|
-
}
|
|
136
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
2
|
+
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
2
3
|
|
|
3
4
|
const COLUMN_TYPES = {
|
|
4
5
|
increments: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
@@ -20,8 +21,14 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
async connect() {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
try {
|
|
25
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
26
|
+
this.db = new DatabaseSync(this.filename)
|
|
27
|
+
} catch (err) {
|
|
28
|
+
const error = normalizeDatabaseError('sqlite', err)
|
|
29
|
+
error.detail = `Database file: ${this.filename}`
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
25
32
|
return this
|
|
26
33
|
}
|
|
27
34
|
|
|
@@ -42,45 +49,48 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
42
49
|
}
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
|
|
52
|
+
// The one place every statement runs through. SQLite reports most
|
|
53
|
+
// mistakes (missing table or column, bad syntax) when the statement
|
|
54
|
+
// is prepared, so prepare() is inside the try as well.
|
|
55
|
+
execute(method, sql, params = []) {
|
|
46
56
|
this.ensureConnected()
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
try {
|
|
58
|
+
return this.db.prepare(sql)[method](...params)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw normalizeDatabaseError('sqlite', err, { sql, params })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async run(sql, params = []) {
|
|
65
|
+
return this.execute('run', sql, params)
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
async get(sql, params = []) {
|
|
52
|
-
this.
|
|
53
|
-
const stmt = this.db.prepare(sql)
|
|
54
|
-
return stmt.get(...params) ?? null
|
|
69
|
+
return this.execute('get', sql, params) ?? null
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
async all(sql, params = []) {
|
|
58
|
-
this.
|
|
59
|
-
const stmt = this.db.prepare(sql)
|
|
60
|
-
return stmt.all(...params)
|
|
73
|
+
return this.execute('all', sql, params)
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
async count(table) {
|
|
64
|
-
this.
|
|
65
|
-
const row = this.db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()
|
|
77
|
+
const row = this.execute('get', `SELECT COUNT(*) as count FROM ${table}`)
|
|
66
78
|
return row.count
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
async paginate(table, limit, offset) {
|
|
70
|
-
this.
|
|
71
|
-
|
|
82
|
+
return this.execute(
|
|
83
|
+
'all',
|
|
72
84
|
`SELECT * FROM ${table} ORDER BY id ASC LIMIT ? OFFSET ?`,
|
|
85
|
+
[limit, offset],
|
|
73
86
|
)
|
|
74
|
-
return stmt.all(limit, offset)
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
async listTables() {
|
|
78
|
-
this.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
)
|
|
83
|
-
.all()
|
|
90
|
+
const rows = this.execute(
|
|
91
|
+
'all',
|
|
92
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
|
93
|
+
)
|
|
84
94
|
return rows.map((r) => r.name)
|
|
85
95
|
}
|
|
86
96
|
|
|
@@ -93,4 +103,4 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
93
103
|
}
|
|
94
104
|
return sql
|
|
95
105
|
}
|
|
96
|
-
}
|
|
106
|
+
}
|