@tylix/orm 0.5.1 → 0.7.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/LICENSE +21 -0
- package/README.md +40 -15
- package/package.json +33 -6
- package/src/adapters/DatabaseAdapter.d.ts +9 -0
- package/src/adapters/DatabaseAdapter.js +33 -2
- package/src/adapters/MongoAdapter.js +95 -16
- package/src/adapters/MysqlAdapter.js +6 -0
- package/src/adapters/PostgresAdapter.js +8 -1
- package/src/adapters/SqliteAdapter.js +54 -1
- package/src/index.d.ts +3 -1
- package/src/index.js +3 -0
- package/src/instrumentation.d.ts +10 -0
- package/src/instrumentation.js +26 -0
- package/src/model/Model.d.ts +51 -10
- package/src/model/Model.js +181 -5
- package/src/model/observers.js +83 -0
- package/src/model/traits.d.ts +30 -0
- package/src/model/traits.js +61 -0
- 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
- package/src/adapters/AdapterFactory.test.js +0 -35
- package/src/adapters/MongoAdapter.test.js +0 -52
- package/src/adapters/MysqlAdapter.test.js +0 -36
- package/src/adapters/PostgresAdapter.test.js +0 -47
- package/src/adapters/SqliteAdapter.test.js +0 -39
- package/src/adapters/query.test.js +0 -60
- package/src/model/Model.paginate.test.js +0 -74
- package/src/model/Model.relations.test.js +0 -86
- package/src/model/Model.test.js +0 -91
- package/src/query/QueryBuilder.test.js +0 -82
- package/src/query/buildSelectSql.test.js +0 -58
- package/tsconfig.json +0 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mbiyrukzz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
# @tylix/orm
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
PostgreSQL, MySQL
|
|
3
|
+
Models, a query builder and migrations-ready adapters for SQLite,
|
|
4
|
+
PostgreSQL, MySQL and MongoDB.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
```sh
|
|
7
|
+
npm install @tylix/orm
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Models
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { Model } from '@tylix/orm'
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
export class Post extends Model {
|
|
16
|
+
static table = 'posts'
|
|
17
|
+
static fillable = ['title', 'body', 'user_id']
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const post = await Post.create({ title: 'Hello', body: '...', user_id: 1 })
|
|
21
|
+
const recent = await Post.where('user_id', 1).latest().limit(10).get()
|
|
22
|
+
const page = await Post.paginate({ page: 2, limit: 20 })
|
|
23
|
+
const claimed = await Job.updateWhere({ id, status: 'pending' }, { status: 'processing' }) // 1 or 0
|
|
24
|
+
```
|
|
14
25
|
|
|
15
|
-
##
|
|
26
|
+
## Observers and traits
|
|
16
27
|
|
|
17
28
|
```js
|
|
18
|
-
import { Model,
|
|
29
|
+
import { Model, Searchable, use } from '@tylix/orm'
|
|
19
30
|
|
|
20
|
-
class
|
|
21
|
-
static table = '
|
|
31
|
+
class Post extends use(Model, Searchable({ fields: ['title', 'body'] })) {
|
|
32
|
+
static table = 'posts'
|
|
33
|
+
static observers = [PostObserver] // creating/created, updating/updated, deleting/deleted
|
|
22
34
|
}
|
|
35
|
+
|
|
36
|
+
const results = await Post.search('tylix')
|
|
23
37
|
```
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
|
|
39
|
+
## Query timing
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { onQuery } from '@tylix/orm'
|
|
43
|
+
|
|
44
|
+
onQuery(({ driver, sql, durationMs }) => { /* log, measure */ })
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The Tylix CLI uses this for slow-query warnings and metrics. Nothing is
|
|
48
|
+
timed while no one listens.
|
|
49
|
+
|
|
50
|
+
SQLite statements are prepared once and reused; a migration run by
|
|
51
|
+
another process is picked up within a second.
|
|
27
52
|
|
|
28
53
|
## License
|
|
29
54
|
|
package/package.json
CHANGED
|
@@ -1,22 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tylix/orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "The Tylix ORM: models with observers and traits, a query builder, and adapters for SQLite, PostgreSQL, MySQL and MongoDB.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Mbiyrukzz",
|
|
4
7
|
"type": "module",
|
|
5
|
-
"main": "./src/index.js",
|
|
6
|
-
"types": "./src/index.d.ts",
|
|
7
8
|
"keywords": [
|
|
8
9
|
"tylix",
|
|
9
10
|
"orm",
|
|
10
11
|
"database",
|
|
11
12
|
"mongodb",
|
|
12
13
|
"mysql",
|
|
13
|
-
"postgresql"
|
|
14
|
+
"postgresql",
|
|
15
|
+
"sqlite",
|
|
16
|
+
"postgres"
|
|
14
17
|
],
|
|
18
|
+
"homepage": "https://github.com/Mbiyrukzz/tylix/tree/main/packages/orm#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/Mbiyrukzz/tylix.git",
|
|
22
|
+
"directory": "packages/orm"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/Mbiyrukzz/tylix/issues"
|
|
26
|
+
},
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=22.13"
|
|
29
|
+
},
|
|
30
|
+
"main": "./src/index.js",
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"src",
|
|
34
|
+
"!**/*.test.js",
|
|
35
|
+
"!**/*.test.mjs",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
15
42
|
"scripts": {
|
|
16
|
-
"test": "node --test src/**/*.test.js"
|
|
43
|
+
"test": "node --test 'src/**/*.test.js'"
|
|
17
44
|
},
|
|
18
45
|
"dependencies": {
|
|
19
|
-
"@tylix/shared": "^0.
|
|
46
|
+
"@tylix/shared": "^0.7.0",
|
|
20
47
|
"mongodb": "^6.5.0",
|
|
21
48
|
"mysql2": "^3.23.1",
|
|
22
49
|
"pg": "^8.11.0"
|
|
@@ -5,6 +5,15 @@ export interface RunResult {
|
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export declare class DatabaseAdapter {
|
|
8
|
+
/** SQL keyword used for LIKE conditions: "LIKE" by default, "ILIKE" on Postgres. */
|
|
9
|
+
readonly likeOperator: 'LIKE' | 'ILIKE';
|
|
10
|
+
|
|
11
|
+
/** Updates rows matching every field of `where` in one statement; resolves to how many changed. */
|
|
12
|
+
updateWhere(table: string, values: Record<string, unknown>, where: Record<string, unknown>): Promise<number>;
|
|
13
|
+
|
|
14
|
+
/** Resolves when the database answers (SELECT 1, or a ping on MongoDB). */
|
|
15
|
+
ping(): Promise<void>;
|
|
16
|
+
|
|
8
17
|
connect(): Promise<this>;
|
|
9
18
|
close(): Promise<void>;
|
|
10
19
|
run(sql: string, params?: unknown[]): Promise<RunResult>;
|
|
@@ -32,6 +32,26 @@ export class DatabaseAdapter {
|
|
|
32
32
|
throw new Error('columnType() not implemented')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Updates the rows matching every field in `where`; resolves to how
|
|
36
|
+
// many rows changed. One statement, so a check-and-set such as
|
|
37
|
+
// { status: 'pending' } -> { status: 'processing' } is atomic.
|
|
38
|
+
async updateWhere(table, values, where) {
|
|
39
|
+
const q = (name) => this.quoteIdentifier(name)
|
|
40
|
+
const sets = Object.keys(values).map((column) => `${q(column)} = ?`)
|
|
41
|
+
const conditions = Object.keys(where).map((column) => `${q(column)} = ?`)
|
|
42
|
+
if (sets.length === 0 || conditions.length === 0) throw new Error('updateWhere() needs values and at least one condition.')
|
|
43
|
+
const result = await this.run(`UPDATE ${q(table)} SET ${sets.join(', ')} WHERE ${conditions.join(' AND ')}`, [
|
|
44
|
+
...Object.values(values),
|
|
45
|
+
...Object.values(where),
|
|
46
|
+
])
|
|
47
|
+
return Number(result?.changes ?? result?.rowCount ?? result?.affectedRows ?? 0)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Resolves when the database answers; used by /ready.
|
|
51
|
+
async ping() {
|
|
52
|
+
await this.get('SELECT 1')
|
|
53
|
+
}
|
|
54
|
+
|
|
35
55
|
async count(table) {
|
|
36
56
|
throw new Error('count() not implemented')
|
|
37
57
|
}
|
|
@@ -44,6 +64,15 @@ export class DatabaseAdapter {
|
|
|
44
64
|
throw new Error('listTables() not implemented')
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The SQL keyword used for LIKE conditions. Case-insensitive on MySQL and
|
|
69
|
+
* SQLite already; PostgresAdapter overrides this with "ILIKE" so all SQL
|
|
70
|
+
* drivers match the same rows.
|
|
71
|
+
*/
|
|
72
|
+
get likeOperator() {
|
|
73
|
+
return 'LIKE'
|
|
74
|
+
}
|
|
75
|
+
|
|
47
76
|
/**
|
|
48
77
|
* Runs a structured query descriptor ({ wheres, orders, limitValue,
|
|
49
78
|
* offsetValue }) against `table` and returns matching rows.
|
|
@@ -55,7 +84,9 @@ export class DatabaseAdapter {
|
|
|
55
84
|
* overrides this entirely since it has no SQL layer to delegate to.
|
|
56
85
|
*/
|
|
57
86
|
async query(table, descriptor = {}) {
|
|
58
|
-
const { sql, params } = buildSelectSql(table, descriptor
|
|
87
|
+
const { sql, params } = buildSelectSql(table, descriptor, {
|
|
88
|
+
likeOperator: this.likeOperator,
|
|
89
|
+
})
|
|
59
90
|
return this.all(sql, params)
|
|
60
91
|
}
|
|
61
|
-
}
|
|
92
|
+
}
|
|
@@ -2,6 +2,50 @@ import { MongoClient } from 'mongodb'
|
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
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
|
+
}
|
|
48
|
+
|
|
5
49
|
export function parseQuery(sql) {
|
|
6
50
|
const trimmed = sql.trim().replace(/\s+/g, ' ')
|
|
7
51
|
|
|
@@ -67,6 +111,17 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
67
111
|
this.counters = {}
|
|
68
112
|
}
|
|
69
113
|
|
|
114
|
+
async updateWhere(table, values, where) {
|
|
115
|
+
this.ensureConnected()
|
|
116
|
+
const result = await this.db.collection(table).updateMany({ ...where }, { $set: { ...values } })
|
|
117
|
+
return result.modifiedCount
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async ping() {
|
|
121
|
+
this.ensureConnected()
|
|
122
|
+
await this.db.command({ ping: 1 })
|
|
123
|
+
}
|
|
124
|
+
|
|
70
125
|
async connect() {
|
|
71
126
|
try {
|
|
72
127
|
// Fail after 5s instead of the driver's default 30s, so a stopped
|
|
@@ -277,29 +332,52 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
277
332
|
async query(table, { wheres = [], orders = [], limitValue, offsetValue } = {}) {
|
|
278
333
|
this.ensureConnected()
|
|
279
334
|
|
|
280
|
-
|
|
335
|
+
let filter = {}
|
|
281
336
|
const sort = {}
|
|
282
337
|
const description = () => {
|
|
283
|
-
|
|
284
|
-
|
|
338
|
+
const show = (value) =>
|
|
339
|
+
JSON.stringify(value, (key, v) => (v instanceof RegExp ? String(v) : v))
|
|
340
|
+
let text = `db.${table}.find(${show(filter)})`
|
|
341
|
+
if (Object.keys(sort).length) text += `.sort(${show(sort)})`
|
|
285
342
|
if (offsetValue !== undefined) text += `.skip(${offsetValue})`
|
|
286
343
|
if (limitValue !== undefined) text += `.limit(${limitValue})`
|
|
287
344
|
return text
|
|
288
345
|
}
|
|
289
346
|
|
|
290
347
|
return this.guard({ get sql() { return description() } }, async () => {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
348
|
+
// Every condition goes into $and, so several conditions on the same
|
|
349
|
+
// field (age > 18 AND age < 65) all apply, like SQL. Values are
|
|
350
|
+
// always wrapped in an operator, so an object from the client can
|
|
351
|
+
// never be interpreted as a query operator itself.
|
|
352
|
+
const conditions = wheres.map((w) => {
|
|
353
|
+
const field = assertField(w.field)
|
|
354
|
+
const operator = String(w.operator).trim().replace(/\s+/g, ' ').toUpperCase()
|
|
355
|
+
|
|
356
|
+
if (operator === '=') return { [field]: { $eq: w.value } }
|
|
357
|
+
if (operator === '!=' || operator === '<>')
|
|
358
|
+
return { [field]: { $ne: w.value } }
|
|
359
|
+
if (operator === '>') return { [field]: { $gt: w.value } }
|
|
360
|
+
if (operator === '<') return { [field]: { $lt: w.value } }
|
|
361
|
+
if (operator === '>=') return { [field]: { $gte: w.value } }
|
|
362
|
+
if (operator === '<=') return { [field]: { $lte: w.value } }
|
|
363
|
+
if (operator === 'LIKE')
|
|
364
|
+
return { [field]: { $regex: likeToRegex(w.value) } }
|
|
365
|
+
|
|
366
|
+
// Mongo treats a missing field as null, so IS NULL also matches
|
|
367
|
+
// documents that don't have the field at all.
|
|
368
|
+
if (operator === 'IS NULL') return { [field]: { $eq: null } }
|
|
369
|
+
if (operator === 'IS NOT NULL') return { [field]: { $ne: null } }
|
|
370
|
+
if (operator === 'IN')
|
|
371
|
+
return { [field]: { $in: assertList(w.value, operator) } }
|
|
372
|
+
if (operator === 'NOT IN')
|
|
373
|
+
return { [field]: { $nin: assertList(w.value, operator) } }
|
|
374
|
+
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
377
|
+
)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
if (conditions.length > 0) filter = { $and: conditions }
|
|
303
381
|
|
|
304
382
|
let cursor = this.db
|
|
305
383
|
.collection(table)
|
|
@@ -307,7 +385,8 @@ export class MongoAdapter extends DatabaseAdapter {
|
|
|
307
385
|
|
|
308
386
|
if (orders.length > 0) {
|
|
309
387
|
orders.forEach((o) => {
|
|
310
|
-
sort[o.field] =
|
|
388
|
+
sort[assertField(o.field)] =
|
|
389
|
+
o.direction.toUpperCase() === 'DESC' ? -1 : 1
|
|
311
390
|
})
|
|
312
391
|
cursor = cursor.sort(sort)
|
|
313
392
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import mysql from 'mysql2/promise'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
5
|
|
|
5
6
|
const COLUMN_TYPES = {
|
|
6
7
|
increments: 'INT AUTO_INCREMENT PRIMARY KEY',
|
|
@@ -10,6 +11,7 @@ const COLUMN_TYPES = {
|
|
|
10
11
|
longText: 'LONGTEXT',
|
|
11
12
|
boolean: 'TINYINT(1)',
|
|
12
13
|
integer: 'INT',
|
|
14
|
+
float: 'DOUBLE',
|
|
13
15
|
date: 'DATE',
|
|
14
16
|
datetime: 'DATETIME',
|
|
15
17
|
json: 'JSON',
|
|
@@ -77,10 +79,14 @@ export class MysqlAdapter extends DatabaseAdapter {
|
|
|
77
79
|
async execute(sql, params = []) {
|
|
78
80
|
this.ensureConnected()
|
|
79
81
|
const converted = params.map(toMysqlDateTime)
|
|
82
|
+
const timed = hasQueryListeners()
|
|
83
|
+
const start = timed ? performance.now() : 0
|
|
80
84
|
try {
|
|
81
85
|
const [result] = await this.connection.execute(sql, converted)
|
|
86
|
+
if (timed) emitQuery({ driver: 'mysql', sql, durationMs: performance.now() - start })
|
|
82
87
|
return result
|
|
83
88
|
} catch (err) {
|
|
89
|
+
if (timed) emitQuery({ driver: 'mysql', sql, durationMs: performance.now() - start, error: err })
|
|
84
90
|
throw normalizeDatabaseError('mysql', err, { sql, params: converted })
|
|
85
91
|
}
|
|
86
92
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import pg from 'pg'
|
|
2
2
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
3
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
4
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
5
|
|
|
5
6
|
const COLUMN_TYPES = {
|
|
6
7
|
increments: 'SERIAL PRIMARY KEY',
|
|
@@ -8,6 +9,7 @@ const COLUMN_TYPES = {
|
|
|
8
9
|
text: 'TEXT',
|
|
9
10
|
boolean: 'BOOLEAN',
|
|
10
11
|
integer: 'INTEGER',
|
|
12
|
+
float: 'DOUBLE PRECISION',
|
|
11
13
|
date: 'DATE',
|
|
12
14
|
datetime: 'TIMESTAMP',
|
|
13
15
|
json: 'JSONB',
|
|
@@ -78,9 +80,14 @@ export class PostgresAdapter extends DatabaseAdapter {
|
|
|
78
80
|
// SQL (with $1, $2 placeholders), exactly what PostgreSQL received.
|
|
79
81
|
async execute(sql, params = []) {
|
|
80
82
|
this.ensureConnected()
|
|
83
|
+
const timed = hasQueryListeners()
|
|
84
|
+
const start = timed ? performance.now() : 0
|
|
81
85
|
try {
|
|
82
|
-
|
|
86
|
+
const result = await this.pool.query(sql, params)
|
|
87
|
+
if (timed) emitQuery({ driver: 'postgres', sql, durationMs: performance.now() - start })
|
|
88
|
+
return result
|
|
83
89
|
} catch (err) {
|
|
90
|
+
if (timed) emitQuery({ driver: 'postgres', sql, durationMs: performance.now() - start, error: err })
|
|
84
91
|
throw normalizeDatabaseError('postgres', err, { sql, params })
|
|
85
92
|
}
|
|
86
93
|
}
|
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
2
2
|
import { normalizeDatabaseError } from './DatabaseError.js'
|
|
3
|
+
import { emitQuery, hasQueryListeners } from '../instrumentation.js'
|
|
4
|
+
|
|
5
|
+
// Prepared statements kept per connection, so a repeated query skips
|
|
6
|
+
// SQLite's parse and plan step. Bounded: the oldest is dropped first.
|
|
7
|
+
// A prepared "SELECT *" keeps the columns it was prepared with, so the
|
|
8
|
+
// cache is cleared when the schema changes: at once for DDL run here, and
|
|
9
|
+
// within a second for a migration run by another process.
|
|
10
|
+
const STATEMENT_CACHE_SIZE = 500
|
|
11
|
+
const SCHEMA_CHECK_MS = 1000
|
|
12
|
+
const DDL = /^\s*(CREATE|ALTER|DROP)\b/i
|
|
3
13
|
|
|
4
14
|
const COLUMN_TYPES = {
|
|
5
15
|
increments: 'INTEGER PRIMARY KEY AUTOINCREMENT',
|
|
@@ -7,6 +17,7 @@ const COLUMN_TYPES = {
|
|
|
7
17
|
text: 'TEXT',
|
|
8
18
|
boolean: 'INTEGER',
|
|
9
19
|
integer: 'INTEGER',
|
|
20
|
+
float: 'REAL',
|
|
10
21
|
date: 'TEXT',
|
|
11
22
|
datetime: 'TEXT',
|
|
12
23
|
json: 'TEXT',
|
|
@@ -18,6 +29,9 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
18
29
|
super()
|
|
19
30
|
this.filename = filename
|
|
20
31
|
this.db = null
|
|
32
|
+
this.statements = new Map()
|
|
33
|
+
this.schemaVersion = null
|
|
34
|
+
this.schemaCheckedAt = 0
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
async connect() {
|
|
@@ -34,6 +48,7 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
34
48
|
|
|
35
49
|
async close() {
|
|
36
50
|
if (this.db) {
|
|
51
|
+
this.statements.clear()
|
|
37
52
|
this.db.close()
|
|
38
53
|
this.db = null
|
|
39
54
|
}
|
|
@@ -54,13 +69,51 @@ export class SqliteAdapter extends DatabaseAdapter {
|
|
|
54
69
|
// is prepared, so prepare() is inside the try as well.
|
|
55
70
|
execute(method, sql, params = []) {
|
|
56
71
|
this.ensureConnected()
|
|
72
|
+
const timed = hasQueryListeners()
|
|
73
|
+
const start = timed ? performance.now() : 0
|
|
57
74
|
try {
|
|
58
|
-
|
|
75
|
+
const result = this.statement(sql)[method](...params)
|
|
76
|
+
if (timed) emitQuery({ driver: 'sqlite', sql, durationMs: performance.now() - start })
|
|
77
|
+
return result
|
|
59
78
|
} catch (err) {
|
|
79
|
+
if (timed) emitQuery({ driver: 'sqlite', sql, durationMs: performance.now() - start, error: err })
|
|
60
80
|
throw normalizeDatabaseError('sqlite', err, { sql, params })
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
83
|
|
|
84
|
+
statement(sql) {
|
|
85
|
+
if (DDL.test(sql)) {
|
|
86
|
+
this.statements.clear()
|
|
87
|
+
this.schemaCheckedAt = 0
|
|
88
|
+
return this.db.prepare(sql)
|
|
89
|
+
}
|
|
90
|
+
const now = Date.now()
|
|
91
|
+
if (now - this.schemaCheckedAt > SCHEMA_CHECK_MS) {
|
|
92
|
+
this.schemaCheckedAt = now
|
|
93
|
+
const { schema_version: version } = this.db.prepare('PRAGMA schema_version').get()
|
|
94
|
+
if (version !== this.schemaVersion) {
|
|
95
|
+
// Reading the schema table makes SQLite reload this connection's
|
|
96
|
+
// copy of the schema; reading the version alone does not, and new
|
|
97
|
+
// statements would still be prepared against the old columns.
|
|
98
|
+
if (this.schemaVersion !== null) this.db.prepare('SELECT 1 FROM sqlite_schema LIMIT 1').get()
|
|
99
|
+
this.statements.clear()
|
|
100
|
+
this.schemaVersion = version
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let statement = this.statements.get(sql)
|
|
104
|
+
if (statement) {
|
|
105
|
+
// Most recently used goes last, so the first entry is the oldest.
|
|
106
|
+
this.statements.delete(sql)
|
|
107
|
+
} else {
|
|
108
|
+
statement = this.db.prepare(sql)
|
|
109
|
+
if (this.statements.size >= STATEMENT_CACHE_SIZE) {
|
|
110
|
+
this.statements.delete(this.statements.keys().next().value)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
this.statements.set(sql, statement)
|
|
114
|
+
return statement
|
|
115
|
+
}
|
|
116
|
+
|
|
64
117
|
async run(sql, params = []) {
|
|
65
118
|
return this.execute('run', sql, params)
|
|
66
119
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -3,9 +3,11 @@ export * from "./adapters/SqliteAdapter.js";
|
|
|
3
3
|
export * from "./adapters/PostgresAdapter.js";
|
|
4
4
|
export * from "./model/ConnectionManager.js";
|
|
5
5
|
export * from "./model/Model.js";
|
|
6
|
+
export * from "./model/traits.js";
|
|
7
|
+
export declare const OBSERVER_EVENTS: readonly ["creating", "created", "updating", "updated", "deleting", "deleted"];
|
|
6
8
|
export * from "./adapters/AdapterFactory.js";
|
|
7
9
|
export * from "./adapters/MysqlAdapter.js";
|
|
8
10
|
export * from "./adapters/MongoAdapter.js";
|
|
9
11
|
export * from "./query/QueryBuilder.js";
|
|
10
12
|
export * from "./query/buildSelectSql.js";
|
|
11
|
-
export * from "./query/types.js";
|
|
13
|
+
export * from "./query/types.js";export * from "./instrumentation.js";
|
package/src/index.js
CHANGED
|
@@ -3,9 +3,12 @@ export * from './adapters/SqliteAdapter.js'
|
|
|
3
3
|
export * from './adapters/PostgresAdapter.js'
|
|
4
4
|
export * from './model/ConnectionManager.js'
|
|
5
5
|
export * from './model/Model.js'
|
|
6
|
+
export * from './model/traits.js'
|
|
7
|
+
export { OBSERVER_EVENTS } from './model/observers.js'
|
|
6
8
|
export * from './adapters/AdapterFactory.js'
|
|
7
9
|
export * from './adapters/MysqlAdapter.js'
|
|
8
10
|
export * from './adapters/MongoAdapter.js'
|
|
9
11
|
export * from './query/QueryBuilder.js'
|
|
10
12
|
export * from './query/buildSelectSql.js'
|
|
11
13
|
export * from './ensureDatabaseExists.js'
|
|
14
|
+
export * from './instrumentation.js'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface QueryEvent {
|
|
2
|
+
driver: string
|
|
3
|
+
sql: string
|
|
4
|
+
durationMs: number
|
|
5
|
+
error?: unknown
|
|
6
|
+
}
|
|
7
|
+
/** Called after every SQL statement; returns a function that removes the listener. */
|
|
8
|
+
export function onQuery(listener: (event: QueryEvent) => void): () => void
|
|
9
|
+
export function hasQueryListeners(): boolean
|
|
10
|
+
export function emitQuery(event: QueryEvent): void
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Query timing for logs and metrics. Every SQL adapter reports each
|
|
2
|
+
// statement it runs, with its duration, to the listeners registered here:
|
|
3
|
+
//
|
|
4
|
+
// const stop = onQuery(({ driver, sql, durationMs, error }) => { ... })
|
|
5
|
+
//
|
|
6
|
+
// With no listeners nothing is timed, so this costs nothing by default.
|
|
7
|
+
const listeners = new Set()
|
|
8
|
+
|
|
9
|
+
export function onQuery(listener) {
|
|
10
|
+
listeners.add(listener)
|
|
11
|
+
return () => listeners.delete(listener)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hasQueryListeners() {
|
|
15
|
+
return listeners.size > 0
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function emitQuery(event) {
|
|
19
|
+
for (const listener of listeners) {
|
|
20
|
+
try {
|
|
21
|
+
listener(event)
|
|
22
|
+
} catch {
|
|
23
|
+
// A broken listener must never fail the query.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|