@tylix/orm 0.5.0 → 0.6.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/package.json +2 -2
- package/src/adapters/DatabaseAdapter.d.ts +3 -0
- package/src/adapters/DatabaseAdapter.js +13 -2
- package/src/adapters/DatabaseError.d.ts +52 -0
- package/src/adapters/DatabaseError.js +336 -0
- package/src/adapters/MongoAdapter.js +192 -77
- package/src/adapters/MysqlAdapter.js +41 -31
- package/src/adapters/PostgresAdapter.js +38 -18
- package/src/adapters/SqliteAdapter.js +33 -23
- package/src/model/Model.d.ts +30 -10
- package/src/model/Model.js +142 -1
- package/src/query/QueryBuilder.d.ts +9 -0
- package/src/query/QueryBuilder.js +61 -2
- package/src/query/buildSelectSql.js +110 -14
- package/src/query/types.d.ts +40 -2
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
import { MongoClient } from 'mongodb'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
|
+
|
|
5
|
+
// Same rule the SQL builder uses: plain identifiers only, with optional
|
|
6
|
+
// dots for nested fields. Rejects "$where", "$or", spaces, quotes, etc.
|
|
7
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/
|
|
8
|
+
|
|
9
|
+
// Same cap as the SQL builder, so a query that works on one driver
|
|
10
|
+
// doesn't fail on another.
|
|
11
|
+
const MAX_LIST_VALUES = 1000
|
|
12
|
+
|
|
13
|
+
function assertField(name) {
|
|
14
|
+
if (typeof name !== 'string' || !IDENTIFIER.test(name)) {
|
|
15
|
+
throw new Error(`Invalid column name "${String(name)}".`)
|
|
16
|
+
}
|
|
17
|
+
return name
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Validates the array given to IN / NOT IN, with the same rules as SQL.
|
|
21
|
+
function assertList(values, operator) {
|
|
22
|
+
if (!Array.isArray(values)) {
|
|
23
|
+
throw new Error(`Operator "${operator}" needs an array of values.`)
|
|
24
|
+
}
|
|
25
|
+
if (values.length > MAX_LIST_VALUES) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`Operator "${operator}" accepts at most ${MAX_LIST_VALUES} values, got ${values.length}.`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
if (values.some((v) => v === null || v === undefined)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`"${operator}" lists cannot contain null or undefined. Use whereNull() / whereNotNull() for null checks.`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return values
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// SQL LIKE -> anchored, case-insensitive regex ("%" = any run, "_" = any
|
|
39
|
+
// single character). Case-insensitive to match MySQL and SQLite.
|
|
40
|
+
function likeToRegex(pattern) {
|
|
41
|
+
if (typeof pattern !== 'string') {
|
|
42
|
+
throw new Error('LIKE needs a string pattern.')
|
|
43
|
+
}
|
|
44
|
+
const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
45
|
+
const source = escaped.replace(/%/g, '.*').replace(/_/g, '.')
|
|
46
|
+
return new RegExp(`^${source}$`, 'is')
|
|
47
|
+
}
|
|
3
48
|
|
|
4
49
|
export function parseQuery(sql) {
|
|
5
50
|
const trimmed = sql.trim().replace(/\s+/g, ' ')
|
|
@@ -26,10 +71,6 @@ export function parseQuery(sql) {
|
|
|
26
71
|
return { type: 'findOne', table: match[1], field: match[2] }
|
|
27
72
|
}
|
|
28
73
|
|
|
29
|
-
if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
30
|
-
return { type: 'findOne', table: match[1], field: match[2] }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
74
|
if ((match = /^SELECT \* FROM (\w+)$/i.exec(trimmed))) {
|
|
34
75
|
return { type: 'findAll', table: match[1] }
|
|
35
76
|
}
|
|
@@ -71,9 +112,22 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
71
112
|
}
|
|
72
113
|
|
|
73
114
|
async connect() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
115
|
+
try {
|
|
116
|
+
// Fail after 5s instead of the driver's default 30s, so a stopped
|
|
117
|
+
// MongoDB server shows up quickly.
|
|
118
|
+
this.client = new MongoClient(this.url, { serverSelectionTimeoutMS: 5000 })
|
|
119
|
+
await this.client.connect()
|
|
120
|
+
this.db = this.client.db(this.dbName)
|
|
121
|
+
} catch (err) {
|
|
122
|
+
const error = normalizeDatabaseError('mongodb', err)
|
|
123
|
+
// Never print credentials: mongodb://user:pass@host -> mongodb://***@host
|
|
124
|
+
const safeUrl = String(this.url).replace(/\/\/[^@/]*@/, '//***@')
|
|
125
|
+
error.detail = `Tried ${safeUrl}, database "${this.dbName}"`
|
|
126
|
+
await this.client?.close().catch(() => {})
|
|
127
|
+
this.client = null
|
|
128
|
+
this.db = null
|
|
129
|
+
throw error
|
|
130
|
+
}
|
|
77
131
|
return this
|
|
78
132
|
}
|
|
79
133
|
|
|
@@ -95,6 +149,18 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
95
149
|
}
|
|
96
150
|
}
|
|
97
151
|
|
|
152
|
+
// Wraps any Mongo operation so failures become DatabaseErrors.
|
|
153
|
+
// `sql` is whatever text best describes what was attempted: the
|
|
154
|
+
// SQL-like string for run/get/all, or an equivalent db.x.find(...)
|
|
155
|
+
// description for query/count/paginate.
|
|
156
|
+
async guard(ctx, fn) {
|
|
157
|
+
try {
|
|
158
|
+
return await fn()
|
|
159
|
+
} catch (err) {
|
|
160
|
+
throw normalizeDatabaseError('mongodb', err, ctx)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
98
164
|
async nextId(table) {
|
|
99
165
|
const counters = this.db.collection('__tylix_counters')
|
|
100
166
|
const result = await counters.findOneAndUpdate(
|
|
@@ -107,6 +173,10 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
107
173
|
|
|
108
174
|
async run(sql, params = []) {
|
|
109
175
|
this.ensureConnected()
|
|
176
|
+
return this.guard({ sql, params }, () => this._run(sql, params))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async _run(sql, params) {
|
|
110
180
|
const query = parseQuery(sql)
|
|
111
181
|
|
|
112
182
|
if (query.type === 'createTable') {
|
|
@@ -179,100 +249,145 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
179
249
|
|
|
180
250
|
async get(sql, params = []) {
|
|
181
251
|
this.ensureConnected()
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
252
|
+
return this.guard({ sql, params }, async () => {
|
|
253
|
+
const query = parseQuery(sql)
|
|
254
|
+
|
|
255
|
+
if (query.type === 'findOne') {
|
|
256
|
+
const doc = await this.db
|
|
257
|
+
.collection(query.table)
|
|
258
|
+
.findOne({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
259
|
+
return doc ?? null
|
|
260
|
+
}
|
|
190
261
|
|
|
191
|
-
|
|
262
|
+
throw new Error(`get() cannot handle query type "${query.type}"`)
|
|
263
|
+
})
|
|
192
264
|
}
|
|
193
265
|
|
|
194
266
|
async all(sql, params = []) {
|
|
195
267
|
this.ensureConnected()
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
268
|
+
return this.guard({ sql, params }, async () => {
|
|
269
|
+
const query = parseQuery(sql)
|
|
270
|
+
|
|
271
|
+
if (query.type === 'findAll') {
|
|
272
|
+
return this.db
|
|
273
|
+
.collection(query.table)
|
|
274
|
+
.find({}, { projection: { _id: 0 } })
|
|
275
|
+
.toArray()
|
|
276
|
+
}
|
|
204
277
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
278
|
+
if (query.type === 'findOne') {
|
|
279
|
+
return this.db
|
|
280
|
+
.collection(query.table)
|
|
281
|
+
.find({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
282
|
+
.toArray()
|
|
283
|
+
}
|
|
211
284
|
|
|
212
|
-
|
|
285
|
+
throw new Error(`all() cannot handle query type "${query.type}"`)
|
|
286
|
+
})
|
|
213
287
|
}
|
|
214
288
|
|
|
215
289
|
async count(table) {
|
|
216
290
|
this.ensureConnected()
|
|
217
|
-
return this.db
|
|
291
|
+
return this.guard({ sql: `db.${table}.countDocuments()` }, () =>
|
|
292
|
+
this.db.collection(table).countDocuments(),
|
|
293
|
+
)
|
|
218
294
|
}
|
|
219
295
|
|
|
220
296
|
async paginate(table, limit, offset) {
|
|
221
297
|
this.ensureConnected()
|
|
222
|
-
return this.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
298
|
+
return this.guard(
|
|
299
|
+
{
|
|
300
|
+
sql: `db.${table}.find({}).sort({"id":1}).skip(${offset}).limit(${limit})`,
|
|
301
|
+
},
|
|
302
|
+
() =>
|
|
303
|
+
this.db
|
|
304
|
+
.collection(table)
|
|
305
|
+
.find({}, { projection: { _id: 0 } })
|
|
306
|
+
.sort({ id: 1 })
|
|
307
|
+
.skip(offset)
|
|
308
|
+
.limit(limit)
|
|
309
|
+
.toArray(),
|
|
310
|
+
)
|
|
229
311
|
}
|
|
230
312
|
|
|
231
313
|
async listTables() {
|
|
232
314
|
this.ensureConnected()
|
|
233
|
-
|
|
234
|
-
|
|
315
|
+
return this.guard({ sql: 'db.listCollections()' }, async () => {
|
|
316
|
+
const collections = await this.db.listCollections().toArray()
|
|
317
|
+
return collections.map((c) => c.name)
|
|
318
|
+
})
|
|
235
319
|
}
|
|
236
320
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
}
|
|
321
|
+
async query(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
|
|
322
|
+
this.ensureConnected()
|
|
241
323
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
}
|
|
324
|
+
let filter = {}
|
|
325
|
+
const sort = {}
|
|
326
|
+
const description = () => {
|
|
327
|
+
const show = (value) =>
|
|
328
|
+
JSON.stringify(value, (key, v) => (v instanceof RegExp ? String(v) : v))
|
|
329
|
+
let text = `db.${table}.find(${show(filter)})`
|
|
330
|
+
if (Object.keys(sort).length) text += `.sort(${show(sort)})`
|
|
331
|
+
if (offsetValue !== undefined) text += `.skip(${offsetValue})`
|
|
332
|
+
if (limitValue !== undefined) text += `.limit(${limitValue})`
|
|
333
|
+
return text
|
|
334
|
+
}
|
|
261
335
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
336
|
+
return this.guard({ get sql() { return description() } }, async () => {
|
|
337
|
+
// Every condition goes into $and, so several conditions on the same
|
|
338
|
+
// field (age > 18 AND age < 65) all apply, like SQL. Values are
|
|
339
|
+
// always wrapped in an operator, so an object from the client can
|
|
340
|
+
// never be interpreted as a query operator itself.
|
|
341
|
+
const conditions = wheres.map((w) => {
|
|
342
|
+
const field = assertField(w.field)
|
|
343
|
+
const operator = String(w.operator).trim().replace(/\s+/g, ' ').toUpperCase()
|
|
344
|
+
|
|
345
|
+
if (operator === '=') return { [field]: { $eq: w.value } }
|
|
346
|
+
if (operator === '!=' || operator === '<>')
|
|
347
|
+
return { [field]: { $ne: w.value } }
|
|
348
|
+
if (operator === '>') return { [field]: { $gt: w.value } }
|
|
349
|
+
if (operator === '<') return { [field]: { $lt: w.value } }
|
|
350
|
+
if (operator === '>=') return { [field]: { $gte: w.value } }
|
|
351
|
+
if (operator === '<=') return { [field]: { $lte: w.value } }
|
|
352
|
+
if (operator === 'LIKE')
|
|
353
|
+
return { [field]: { $regex: likeToRegex(w.value) } }
|
|
354
|
+
|
|
355
|
+
// Mongo treats a missing field as null, so IS NULL also matches
|
|
356
|
+
// documents that don't have the field at all.
|
|
357
|
+
if (operator === 'IS NULL') return { [field]: { $eq: null } }
|
|
358
|
+
if (operator === 'IS NOT NULL') return { [field]: { $ne: null } }
|
|
359
|
+
if (operator === 'IN')
|
|
360
|
+
return { [field]: { $in: assertList(w.value, operator) } }
|
|
361
|
+
if (operator === 'NOT IN')
|
|
362
|
+
return { [field]: { $nin: assertList(w.value, operator) } }
|
|
363
|
+
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
366
|
+
)
|
|
367
|
+
})
|
|
265
368
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
369
|
+
if (conditions.length > 0) filter = { $and: conditions }
|
|
370
|
+
|
|
371
|
+
let cursor = this.db
|
|
372
|
+
.collection(table)
|
|
373
|
+
.find(filter, { projection: { _id: 0 } })
|
|
374
|
+
|
|
375
|
+
if (orders.length > 0) {
|
|
376
|
+
orders.forEach((o) => {
|
|
377
|
+
sort[assertField(o.field)] =
|
|
378
|
+
o.direction.toUpperCase() === 'DESC' ? -1 : 1
|
|
379
|
+
})
|
|
380
|
+
cursor = cursor.sort(sort)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (offsetValue !== undefined) cursor = cursor.skip(offsetValue)
|
|
384
|
+
if (limitValue !== undefined) cursor = cursor.limit(limitValue)
|
|
385
|
+
|
|
386
|
+
return cursor.toArray()
|
|
270
387
|
})
|
|
271
|
-
cursor = cursor.sort(sort)
|
|
272
388
|
}
|
|
273
389
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
390
|
+
columnType() {
|
|
391
|
+
return null
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -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
|
+
}
|