@tylix/orm 0.1.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 +15 -0
- package/src/adapters/AdapterFactory.js +24 -0
- package/src/adapters/AdapterFactory.test.js +35 -0
- package/src/adapters/DatabaseAdapter.js +61 -0
- package/src/adapters/MongoAdapter.js +274 -0
- package/src/adapters/MongoAdapter.test.js +52 -0
- package/src/adapters/MysqlAdapter.js +107 -0
- package/src/adapters/MysqlAdapter.test.js +36 -0
- package/src/adapters/PostgresAdapter.js +113 -0
- package/src/adapters/PostgresAdapter.test.js +47 -0
- package/src/adapters/SqliteAdapter.js +92 -0
- package/src/adapters/SqliteAdapter.test.js +39 -0
- package/src/adapters/query.test.js +60 -0
- package/src/index.js +9 -0
- package/src/model/ConnectionManager.js +20 -0
- package/src/model/Model.js +141 -0
- package/src/model/Model.paginate.test.js +74 -0
- package/src/model/Model.relations.test.js +86 -0
- package/src/model/Model.test.js +91 -0
- package/src/query/QueryBuilder.js +66 -0
- package/src/query/QueryBuilder.test.js +82 -0
- package/src/query/buildSelectSql.js +50 -0
- package/src/query/buildSelectSql.test.js +58 -0
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tylix/orm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test src/**/*.test.js"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@tylix/shared": "*",
|
|
11
|
+
"mongodb": "^6.5.0",
|
|
12
|
+
"mysql2": "^3.9.0",
|
|
13
|
+
"pg": "^8.11.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SqliteAdapter } from "./SqliteAdapter.js";
|
|
2
|
+
import { PostgresAdapter } from "./PostgresAdapter.js";
|
|
3
|
+
import { MysqlAdapter } from "./MysqlAdapter.js";
|
|
4
|
+
import { MongoAdapter } from "./MongoAdapter.js";
|
|
5
|
+
|
|
6
|
+
const ADAPTERS = {
|
|
7
|
+
sqlite: SqliteAdapter,
|
|
8
|
+
postgres: PostgresAdapter,
|
|
9
|
+
mysql: MysqlAdapter,
|
|
10
|
+
mongodb: MongoAdapter,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createAdapter(databaseConfig) {
|
|
14
|
+
const { driver, ...options } = databaseConfig;
|
|
15
|
+
|
|
16
|
+
const AdapterClass = ADAPTERS[driver];
|
|
17
|
+
if (!AdapterClass) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Unknown database driver "${driver}". Available drivers: ${Object.keys(ADAPTERS).join(", ")}`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return new AdapterClass(options);
|
|
24
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createAdapter } from "./AdapterFactory.js";
|
|
4
|
+
import { SqliteAdapter } from "./SqliteAdapter.js";
|
|
5
|
+
import { PostgresAdapter } from "./PostgresAdapter.js";
|
|
6
|
+
import { MysqlAdapter } from "./MysqlAdapter.js";
|
|
7
|
+
import { MongoAdapter } from "./MongoAdapter.js";
|
|
8
|
+
|
|
9
|
+
test("creates a SqliteAdapter for driver: sqlite", () => {
|
|
10
|
+
const adapter = createAdapter({ driver: "sqlite", filename: "test.sqlite" });
|
|
11
|
+
assert.ok(adapter instanceof SqliteAdapter);
|
|
12
|
+
assert.equal(adapter.filename, "test.sqlite");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("throws on unknown driver", () => {
|
|
16
|
+
assert.throws(
|
|
17
|
+
() => createAdapter({ driver: "oracle" }),
|
|
18
|
+
/Unknown database driver "oracle"/
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("creates a PostgresAdapter for driver: postgres", () => {
|
|
23
|
+
const adapter = createAdapter({ driver: "postgres", database: "test" });
|
|
24
|
+
assert.ok(adapter instanceof PostgresAdapter);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("creates a MysqlAdapter for driver: mysql", () => {
|
|
28
|
+
const adapter = createAdapter({ driver: "mysql", database: "test" });
|
|
29
|
+
assert.ok(adapter instanceof MysqlAdapter);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("creates a MongoAdapter for driver: mongodb", () => {
|
|
33
|
+
const adapter = createAdapter({ driver: "mongodb", database: "test" });
|
|
34
|
+
assert.ok(adapter instanceof MongoAdapter);
|
|
35
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { buildSelectSql } from '../query/buildSelectSql.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base contract every Tylix database adapter must implement.
|
|
5
|
+
* Concrete adapters (SqliteAdapter, PostgresAdapter, MysqlAdapter,
|
|
6
|
+
* MongoAdapter...) extend this and implement each method. Model and the
|
|
7
|
+
* migration runner only ever talk to this interface, never to a specific
|
|
8
|
+
* driver.
|
|
9
|
+
*/
|
|
10
|
+
export class DatabaseAdapter {
|
|
11
|
+
async connect() {
|
|
12
|
+
throw new Error('connect() not implemented')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async close() {
|
|
16
|
+
throw new Error('close() not implemented')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async run(sql, params = []) {
|
|
20
|
+
throw new Error('run() not implemented')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async get(sql, params = []) {
|
|
24
|
+
throw new Error('get() not implemented')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async all(sql, params = []) {
|
|
28
|
+
throw new Error('all() not implemented')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
columnType(logicalType) {
|
|
32
|
+
throw new Error('columnType() not implemented')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async count(table) {
|
|
36
|
+
throw new Error('count() not implemented')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async paginate(table, limit, offset) {
|
|
40
|
+
throw new Error('paginate() not implemented')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async listTables() {
|
|
44
|
+
throw new Error('listTables() not implemented')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runs a structured query descriptor ({ wheres, orders, limitValue,
|
|
49
|
+
* offsetValue }) against `table` and returns matching rows.
|
|
50
|
+
*
|
|
51
|
+
* Default implementation translates the descriptor to "?"-placeholder
|
|
52
|
+
* SQL via buildSelectSql() and delegates to this.all(), which every
|
|
53
|
+
* SQL adapter (Sqlite/Postgres/Mysql) already implements correctly
|
|
54
|
+
* for its own placeholder dialect and type conversions. MongoAdapter
|
|
55
|
+
* overrides this entirely since it has no SQL layer to delegate to.
|
|
56
|
+
*/
|
|
57
|
+
async query(table, descriptor = {}) {
|
|
58
|
+
const { sql, params } = buildSelectSql(table, descriptor)
|
|
59
|
+
return this.all(sql, params)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { MongoClient } from 'mongodb'
|
|
2
|
+
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
|
|
4
|
+
export function parseQuery(sql) {
|
|
5
|
+
const trimmed = sql.trim().replace(/\s+/g, ' ')
|
|
6
|
+
|
|
7
|
+
let match
|
|
8
|
+
|
|
9
|
+
if ((match = /^CREATE TABLE IF NOT EXISTS (\w+)/i.exec(trimmed))) {
|
|
10
|
+
return { type: 'createTable', table: match[1] }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if ((match = /^DROP TABLE IF EXISTS (\w+)/i.exec(trimmed))) {
|
|
14
|
+
return { type: 'dropTable', table: match[1] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if ((match = /^ALTER TABLE (\w+) ADD COLUMN (\w+)/i.exec(trimmed))) {
|
|
18
|
+
return { type: 'alterAddColumn', table: match[1], column: match[2] }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if ((match = /^ALTER TABLE (\w+) DROP COLUMN (\w+)$/i.exec(trimmed))) {
|
|
22
|
+
return { type: 'alterDropColumn', table: match[1], column: match[2] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
26
|
+
return { type: 'findOne', table: match[1], field: match[2] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if ((match = /^SELECT \* FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
30
|
+
return { type: 'findOne', table: match[1], field: match[2] }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if ((match = /^SELECT \* FROM (\w+)$/i.exec(trimmed))) {
|
|
34
|
+
return { type: 'findAll', table: match[1] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (
|
|
38
|
+
(match = /^INSERT INTO (\w+) \(([^)]+)\) VALUES \(([^)]+)\)$/i.exec(
|
|
39
|
+
trimmed,
|
|
40
|
+
))
|
|
41
|
+
) {
|
|
42
|
+
const columns = match[2].split(',').map((c) => c.trim())
|
|
43
|
+
return { type: 'insert', table: match[1], columns }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ((match = /^UPDATE (\w+) SET (.+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
47
|
+
const setClause = match[2]
|
|
48
|
+
const columns = setClause
|
|
49
|
+
.split(',')
|
|
50
|
+
.map((pair) => pair.trim().split('=')[0].trim())
|
|
51
|
+
return { type: 'update', table: match[1], columns, whereField: match[3] }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ((match = /^DELETE FROM (\w+) WHERE (\w+) = \?$/i.exec(trimmed))) {
|
|
55
|
+
return { type: 'delete', table: match[1], field: match[2] }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(
|
|
59
|
+
`MongoAdapter does not recognize this query shape: "${trimmed}"`,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class MongoAdapter extends DatabaseAdapter {
|
|
64
|
+
constructor({ url = 'mongodb://localhost:27017', database } = {}) {
|
|
65
|
+
super()
|
|
66
|
+
this.url = url
|
|
67
|
+
this.dbName = database
|
|
68
|
+
this.client = null
|
|
69
|
+
this.db = null
|
|
70
|
+
this.counters = {}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async connect() {
|
|
74
|
+
this.client = new MongoClient(this.url)
|
|
75
|
+
await this.client.connect()
|
|
76
|
+
this.db = this.client.db(this.dbName)
|
|
77
|
+
return this
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async close() {
|
|
81
|
+
if (this.client) {
|
|
82
|
+
await this.client.close()
|
|
83
|
+
this.client = null
|
|
84
|
+
this.db = null
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ensureConnected() {
|
|
89
|
+
if (!this.db) {
|
|
90
|
+
throw new Error('MongoAdapter is not connected. Call connect() first.')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async nextId(table) {
|
|
95
|
+
const counters = this.db.collection('__tylix_counters')
|
|
96
|
+
const result = await counters.findOneAndUpdate(
|
|
97
|
+
{ _id: table },
|
|
98
|
+
{ $inc: { seq: 1 } },
|
|
99
|
+
{ upsert: true, returnDocument: 'after' },
|
|
100
|
+
)
|
|
101
|
+
return result.seq
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async run(sql, params = []) {
|
|
105
|
+
this.ensureConnected()
|
|
106
|
+
const query = parseQuery(sql)
|
|
107
|
+
|
|
108
|
+
if (query.type === 'createTable') {
|
|
109
|
+
const collections = await this.db
|
|
110
|
+
.listCollections({ name: query.table })
|
|
111
|
+
.toArray()
|
|
112
|
+
if (collections.length === 0) {
|
|
113
|
+
await this.db.createCollection(query.table)
|
|
114
|
+
}
|
|
115
|
+
return {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (query.type === 'dropTable') {
|
|
119
|
+
const collections = await this.db
|
|
120
|
+
.listCollections({ name: query.table })
|
|
121
|
+
.toArray()
|
|
122
|
+
if (collections.length > 0) {
|
|
123
|
+
await this.db.collection(query.table).drop()
|
|
124
|
+
}
|
|
125
|
+
return {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (query.type === 'alterAddColumn') {
|
|
129
|
+
return {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Unlike ADD, DROP needs a real write: old documents still carry
|
|
133
|
+
// the removed field's data, and it would keep coming back in
|
|
134
|
+
// find()/query() results (Model.all(), etc.) forever unless
|
|
135
|
+
// explicitly stripped from every existing document.
|
|
136
|
+
if (query.type === 'alterDropColumn') {
|
|
137
|
+
await this.db
|
|
138
|
+
.collection(query.table)
|
|
139
|
+
.updateMany({}, { $unset: { [query.column]: '' } })
|
|
140
|
+
return {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (query.type === 'insert') {
|
|
144
|
+
const id = await this.nextId(query.table)
|
|
145
|
+
const doc = { id }
|
|
146
|
+
query.columns.forEach((col, i) => {
|
|
147
|
+
doc[col] = params[i]
|
|
148
|
+
})
|
|
149
|
+
await this.db.collection(query.table).insertOne(doc)
|
|
150
|
+
return { lastInsertRowid: id }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (query.type === 'update') {
|
|
154
|
+
const whereValue = params[params.length - 1]
|
|
155
|
+
const setValues = params.slice(0, params.length - 1)
|
|
156
|
+
const $set = {}
|
|
157
|
+
query.columns.forEach((col, i) => {
|
|
158
|
+
$set[col] = setValues[i]
|
|
159
|
+
})
|
|
160
|
+
await this.db
|
|
161
|
+
.collection(query.table)
|
|
162
|
+
.updateOne({ [query.whereField]: whereValue }, { $set })
|
|
163
|
+
return {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (query.type === 'delete') {
|
|
167
|
+
await this.db
|
|
168
|
+
.collection(query.table)
|
|
169
|
+
.deleteOne({ [query.field]: params[0] })
|
|
170
|
+
return {}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
throw new Error(`run() cannot handle query type "${query.type}"`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async get(sql, params = []) {
|
|
177
|
+
this.ensureConnected()
|
|
178
|
+
const query = parseQuery(sql)
|
|
179
|
+
|
|
180
|
+
if (query.type === 'findOne') {
|
|
181
|
+
const doc = await this.db
|
|
182
|
+
.collection(query.table)
|
|
183
|
+
.findOne({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
184
|
+
return doc ?? null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw new Error(`get() cannot handle query type "${query.type}"`)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async all(sql, params = []) {
|
|
191
|
+
this.ensureConnected()
|
|
192
|
+
const query = parseQuery(sql)
|
|
193
|
+
|
|
194
|
+
if (query.type === 'findAll') {
|
|
195
|
+
return this.db
|
|
196
|
+
.collection(query.table)
|
|
197
|
+
.find({}, { projection: { _id: 0 } })
|
|
198
|
+
.toArray()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (query.type === 'findOne') {
|
|
202
|
+
return this.db
|
|
203
|
+
.collection(query.table)
|
|
204
|
+
.find({ [query.field]: params[0] }, { projection: { _id: 0 } })
|
|
205
|
+
.toArray()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
throw new Error(`all() cannot handle query type "${query.type}"`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async count(table) {
|
|
212
|
+
this.ensureConnected()
|
|
213
|
+
return this.db.collection(table).countDocuments()
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async paginate(table, limit, offset) {
|
|
217
|
+
this.ensureConnected()
|
|
218
|
+
return this.db
|
|
219
|
+
.collection(table)
|
|
220
|
+
.find({}, { projection: { _id: 0 } })
|
|
221
|
+
.sort({ id: 1 })
|
|
222
|
+
.skip(offset)
|
|
223
|
+
.limit(limit)
|
|
224
|
+
.toArray()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async listTables() {
|
|
228
|
+
this.ensureConnected()
|
|
229
|
+
const collections = await this.db.listCollections().toArray()
|
|
230
|
+
return collections.map((c) => c.name)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
columnType() {
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
MongoAdapter.prototype.query = async function (
|
|
239
|
+
table,
|
|
240
|
+
{ wheres = [], orders = [], limitValue, offsetValue } = {},
|
|
241
|
+
) {
|
|
242
|
+
this.ensureConnected()
|
|
243
|
+
|
|
244
|
+
const filter = {}
|
|
245
|
+
for (const w of wheres) {
|
|
246
|
+
if (w.operator === '=') filter[w.field] = w.value
|
|
247
|
+
else if (w.operator === '!=') filter[w.field] = { $ne: w.value }
|
|
248
|
+
else if (w.operator === '>') filter[w.field] = { $gt: w.value }
|
|
249
|
+
else if (w.operator === '<') filter[w.field] = { $lt: w.value }
|
|
250
|
+
else if (w.operator === '>=') filter[w.field] = { $gte: w.value }
|
|
251
|
+
else if (w.operator === '<=') filter[w.field] = { $lte: w.value }
|
|
252
|
+
else
|
|
253
|
+
throw new Error(
|
|
254
|
+
`Unsupported where operator "${w.operator}" for MongoAdapter`,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let cursor = this.db
|
|
259
|
+
.collection(table)
|
|
260
|
+
.find(filter, { projection: { _id: 0 } })
|
|
261
|
+
|
|
262
|
+
if (orders.length > 0) {
|
|
263
|
+
const sort = {}
|
|
264
|
+
orders.forEach((o) => {
|
|
265
|
+
sort[o.field] = o.direction.toUpperCase() === 'DESC' ? -1 : 1
|
|
266
|
+
})
|
|
267
|
+
cursor = cursor.sort(sort)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (offsetValue !== undefined) cursor = cursor.skip(offsetValue)
|
|
271
|
+
if (limitValue !== undefined) cursor = cursor.limit(limitValue)
|
|
272
|
+
|
|
273
|
+
return cursor.toArray()
|
|
274
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseQuery, MongoAdapter } from "./MongoAdapter.js";
|
|
4
|
+
|
|
5
|
+
test("recognizes SELECT * FROM table", () => {
|
|
6
|
+
assert.deepEqual(parseQuery("SELECT * FROM posts"), { type: "findAll", table: "posts" });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("recognizes SELECT * FROM table WHERE field = ?", () => {
|
|
10
|
+
assert.deepEqual(parseQuery("SELECT * FROM posts WHERE id = ?"), {
|
|
11
|
+
type: "findOne",
|
|
12
|
+
table: "posts",
|
|
13
|
+
field: "id",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("recognizes INSERT INTO table (cols) VALUES (?...)", () => {
|
|
18
|
+
const result = parseQuery("INSERT INTO posts (title, body) VALUES (?, ?)");
|
|
19
|
+
assert.equal(result.type, "insert");
|
|
20
|
+
assert.equal(result.table, "posts");
|
|
21
|
+
assert.deepEqual(result.columns, ["title", "body"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("recognizes UPDATE table SET col=?,col=? WHERE field = ?", () => {
|
|
25
|
+
const result = parseQuery("UPDATE posts SET title = ?, body = ? WHERE id = ?");
|
|
26
|
+
assert.equal(result.type, "update");
|
|
27
|
+
assert.deepEqual(result.columns, ["title", "body"]);
|
|
28
|
+
assert.equal(result.whereField, "id");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("recognizes DELETE FROM table WHERE field = ?", () => {
|
|
32
|
+
assert.deepEqual(parseQuery("DELETE FROM posts WHERE id = ?"), {
|
|
33
|
+
type: "delete",
|
|
34
|
+
table: "posts",
|
|
35
|
+
field: "id",
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("recognizes CREATE TABLE IF NOT EXISTS as a no-op shape", () => {
|
|
40
|
+
const result = parseQuery("CREATE TABLE IF NOT EXISTS posts (id INT)");
|
|
41
|
+
assert.equal(result.type, "createTable");
|
|
42
|
+
assert.equal(result.table, "posts");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("throws clearly on an unrecognized query shape", () => {
|
|
46
|
+
assert.throws(() => parseQuery("SELECT COUNT(*) FROM posts"), /does not recognize/);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("ensureConnected throws before connect() is called", async () => {
|
|
50
|
+
const adapter = new MongoAdapter({ database: "test" });
|
|
51
|
+
await assert.rejects(() => adapter.all("SELECT * FROM posts"));
|
|
52
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise'
|
|
2
|
+
import { DatabaseAdapter } from './DatabaseAdapter.js'
|
|
3
|
+
|
|
4
|
+
const COLUMN_TYPES = {
|
|
5
|
+
increments: 'INT AUTO_INCREMENT PRIMARY KEY',
|
|
6
|
+
string: 'VARCHAR(255)',
|
|
7
|
+
text: 'TEXT',
|
|
8
|
+
boolean: 'TINYINT(1)',
|
|
9
|
+
integer: 'INT',
|
|
10
|
+
date: 'DATE',
|
|
11
|
+
datetime: 'DATETIME',
|
|
12
|
+
json: 'JSON',
|
|
13
|
+
timestamp: 'DATETIME',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ISO_DATETIME_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/
|
|
17
|
+
|
|
18
|
+
export function toMysqlDateTime(value) {
|
|
19
|
+
if (typeof value === 'string' && ISO_DATETIME_PATTERN.test(value)) {
|
|
20
|
+
return value.slice(0, 19).replace('T', ' ')
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class MysqlAdapter extends DatabaseAdapter {
|
|
26
|
+
constructor({ host, port, user, password, database } = {}) {
|
|
27
|
+
super()
|
|
28
|
+
this.config = { host, port, user, password, database }
|
|
29
|
+
this.connection = null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async connect() {
|
|
33
|
+
this.connection = await mysql.createConnection(this.config)
|
|
34
|
+
return this
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async close() {
|
|
38
|
+
if (this.connection) {
|
|
39
|
+
await this.connection.end()
|
|
40
|
+
this.connection = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ensureConnected() {
|
|
45
|
+
if (!this.connection) {
|
|
46
|
+
throw new Error('MysqlAdapter is not connected. Call connect() first.')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async run(sql, params = []) {
|
|
51
|
+
this.ensureConnected()
|
|
52
|
+
const converted = params.map(toMysqlDateTime)
|
|
53
|
+
const [result] = await this.connection.execute(sql, converted)
|
|
54
|
+
return { ...result, lastInsertRowid: result.insertId }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async get(sql, params = []) {
|
|
58
|
+
this.ensureConnected()
|
|
59
|
+
const converted = params.map(toMysqlDateTime)
|
|
60
|
+
const [rows] = await this.connection.execute(sql, converted)
|
|
61
|
+
return rows[0] ?? null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async all(sql, params = []) {
|
|
65
|
+
this.ensureConnected()
|
|
66
|
+
const converted = params.map(toMysqlDateTime)
|
|
67
|
+
const [rows] = await this.connection.execute(sql, converted)
|
|
68
|
+
return rows
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async count(table) {
|
|
72
|
+
this.ensureConnected()
|
|
73
|
+
const [rows] = await this.connection.execute(
|
|
74
|
+
`SELECT COUNT(*) as count FROM ${table}`,
|
|
75
|
+
)
|
|
76
|
+
return Number(rows[0].count)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async paginate(table, limit, offset) {
|
|
80
|
+
this.ensureConnected()
|
|
81
|
+
// mysql2 does not support ? placeholders for LIMIT/OFFSET reliably
|
|
82
|
+
// across versions, so these are inlined after being forced to
|
|
83
|
+
// integers here (never from raw user input) to avoid injection.
|
|
84
|
+
const safeLimit = Number.isInteger(limit) ? limit : parseInt(limit, 10)
|
|
85
|
+
const safeOffset = Number.isInteger(offset) ? offset : parseInt(offset, 10)
|
|
86
|
+
const [rows] = await this.connection.execute(
|
|
87
|
+
`SELECT * FROM ${table} ORDER BY id ASC LIMIT ${safeLimit} OFFSET ${safeOffset}`,
|
|
88
|
+
)
|
|
89
|
+
return rows
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async listTables() {
|
|
93
|
+
this.ensureConnected()
|
|
94
|
+
const [rows] = await this.connection.execute('SHOW TABLES')
|
|
95
|
+
return rows.map((r) => Object.values(r)[0])
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
columnType(logicalType) {
|
|
99
|
+
const sql = COLUMN_TYPES[logicalType]
|
|
100
|
+
if (!sql) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`MysqlAdapter has no mapping for column type "${logicalType}"`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return sql
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { MysqlAdapter } from "./MysqlAdapter.js";
|
|
4
|
+
|
|
5
|
+
test("columnType maps logical types to MySQL SQL types", () => {
|
|
6
|
+
const adapter = new MysqlAdapter({ database: "test" });
|
|
7
|
+
assert.equal(adapter.columnType("increments"), "INT AUTO_INCREMENT PRIMARY KEY");
|
|
8
|
+
assert.equal(adapter.columnType("string"), "VARCHAR(255)");
|
|
9
|
+
assert.equal(adapter.columnType("boolean"), "TINYINT(1)");
|
|
10
|
+
assert.equal(adapter.columnType("json"), "JSON");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("columnType throws on unknown logical type", () => {
|
|
14
|
+
const adapter = new MysqlAdapter({ database: "test" });
|
|
15
|
+
assert.throws(() => adapter.columnType("nonsense"), /no mapping/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("ensureConnected throws before connect() is called", async () => {
|
|
19
|
+
const adapter = new MysqlAdapter({ database: "test" });
|
|
20
|
+
await assert.rejects(() => adapter.all("SELECT 1"));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("toMysqlDateTime converts ISO 8601 strings to MySQL DATETIME format", async () => {
|
|
24
|
+
const { toMysqlDateTime } = await import("./MysqlAdapter.js");
|
|
25
|
+
assert.equal(
|
|
26
|
+
toMysqlDateTime("2026-07-14T03:35:14.083Z"),
|
|
27
|
+
"2026-07-14 03:35:14"
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("toMysqlDateTime leaves non-ISO-datetime values untouched", async () => {
|
|
32
|
+
const { toMysqlDateTime } = await import("./MysqlAdapter.js");
|
|
33
|
+
assert.equal(toMysqlDateTime("Hello Tylix"), "Hello Tylix");
|
|
34
|
+
assert.equal(toMysqlDateTime(42), 42);
|
|
35
|
+
assert.equal(toMysqlDateTime(true), true);
|
|
36
|
+
});
|