@bakery-framework/orm 1.0.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 +19 -0
- package/README.md +88 -0
- package/package.json +59 -0
- package/src/adapters/base.ts +1128 -0
- package/src/adapters/mysql.ts +619 -0
- package/src/adapters/observe.ts +261 -0
- package/src/adapters/pgsql.ts +611 -0
- package/src/adapters/registry.ts +204 -0
- package/src/adapters/sqlite.ts +588 -0
- package/src/adapters.ts +72 -0
- package/src/backup.ts +37 -0
- package/src/connection.ts +69 -0
- package/src/define.ts +380 -0
- package/src/field.ts +595 -0
- package/src/globals.d.ts +22 -0
- package/src/index.ts +63 -0
- package/src/orm/index.ts +24 -0
- package/src/orm/mutation.ts +692 -0
- package/src/orm/query.ts +1680 -0
- package/src/pool.ts +83 -0
- package/src/schema-registry.ts +75 -0
- package/src/schema-util.ts +467 -0
- package/src/sync/builder.ts +618 -0
- package/src/sync/engine.ts +399 -0
- package/src/sync/helpers.ts +1119 -0
- package/src/sync/history.ts +169 -0
- package/src/sync/index.ts +113 -0
- package/src/sync/ledger.ts +335 -0
- package/src/sync/load.ts +368 -0
- package/src/sync/rollback.ts +200 -0
- package/src/sync/types.ts +101 -0
- package/src/sync/view-sql.ts +160 -0
- package/templates/schema.example.ts +94 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { Case, Try } from '@bakery-framework/core/utils'
|
|
2
|
+
import { SQL } from 'bun'
|
|
3
|
+
import { type PoolOptions, withPoolOptions } from '../pool'
|
|
4
|
+
import type * as SyncTypes from '../sync/types'
|
|
5
|
+
import { createExecutor, isOpenConnection, SQLAdapter } from './base'
|
|
6
|
+
|
|
7
|
+
export class MySQLAdapter extends SQLAdapter {
|
|
8
|
+
protected readonly sql: SQL
|
|
9
|
+
|
|
10
|
+
constructor(connectionTarget?: string | URL | SQL, pool: PoolOptions = {}) {
|
|
11
|
+
const target =
|
|
12
|
+
typeof connectionTarget === 'string' || connectionTarget instanceof URL
|
|
13
|
+
? connectionTarget.toString().replace(/^(mysqli?s?:\/\/)/, 'mysql://')
|
|
14
|
+
: undefined
|
|
15
|
+
super('mysql', undefined, target)
|
|
16
|
+
// `isOpenConnection`, not `instanceof SQL` — see the helper for why the
|
|
17
|
+
// latter throws rather than answering.
|
|
18
|
+
//
|
|
19
|
+
// Pool options apply only when this opens its own connection. A handle
|
|
20
|
+
// handed in is already someone else's pool — notably a transaction's, where
|
|
21
|
+
// re-sizing anything would be meaningless.
|
|
22
|
+
this.sql = isOpenConnection(connectionTarget)
|
|
23
|
+
? (connectionTarget as SQL)
|
|
24
|
+
: target
|
|
25
|
+
? new SQL(target, withPoolOptions({}, pool) as any)
|
|
26
|
+
: new SQL(withPoolOptions({}, pool) as any)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
readonly execute: SQLAdapter.Executor = createExecutor(
|
|
30
|
+
async (sqlText: string, params: unknown[] = []) =>
|
|
31
|
+
(await this.sql.unsafe(sqlText, params)) as SQLAdapter.RowRecord[],
|
|
32
|
+
async (
|
|
33
|
+
sqlText: string,
|
|
34
|
+
params: unknown[] = [],
|
|
35
|
+
): Promise<SQLAdapter.RunResult> => {
|
|
36
|
+
const rows = (await this.sql.unsafe(sqlText, params)) as any
|
|
37
|
+
return {
|
|
38
|
+
lastInsertRowid:
|
|
39
|
+
rows?.insertId ?? rows?.lastInsertRowid ?? rows?.lastInsertId ?? null,
|
|
40
|
+
// `affectedRows` first, and the order is the whole fix.
|
|
41
|
+
//
|
|
42
|
+
// Bun's MySQL driver sets `count` to **0** for every write and puts the
|
|
43
|
+
// real number in `affectedRows` — the reverse of SQLite, where `count`
|
|
44
|
+
// is authoritative and `affectedRows` is null. Reading `count` first
|
|
45
|
+
// through a `??` chain therefore never fell through: zero is not
|
|
46
|
+
// nullish, so it was taken and returned. Every insert, update and
|
|
47
|
+
// delete on MySQL reported `changes: 0`.
|
|
48
|
+
//
|
|
49
|
+
// Not cosmetic: `Mutation`'s batched insert sums this to report how
|
|
50
|
+
// many rows it wrote, the dashboard prints it after a CSV import, and
|
|
51
|
+
// it is the row count on every `run` event the query observer emits.
|
|
52
|
+
// All three said zero on MySQL.
|
|
53
|
+
changes: Number(
|
|
54
|
+
rows?.affectedRows ?? rows?.changedRows ?? rows?.count ?? 0,
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
this.driver,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
async hasCol(table: string, column: string): Promise<boolean> {
|
|
62
|
+
const res = (await this.query(
|
|
63
|
+
'SELECT column_name AS column_name' +
|
|
64
|
+
' FROM information_schema.columns' +
|
|
65
|
+
' WHERE table_name = ? AND table_schema = DATABASE()',
|
|
66
|
+
).all(table)) as SQLAdapter.ColumnNameRow[]
|
|
67
|
+
return res.some(r => r.column_name === column)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
colDef(def: unknown, column?: string): string {
|
|
71
|
+
const d = def as any
|
|
72
|
+
const typeStr =
|
|
73
|
+
{
|
|
74
|
+
integer: 'INT',
|
|
75
|
+
string: 'TEXT',
|
|
76
|
+
number: 'DOUBLE',
|
|
77
|
+
boolean: 'TINYINT(1)',
|
|
78
|
+
buffer: 'BLOB',
|
|
79
|
+
bigint: 'BIGINT',
|
|
80
|
+
json: 'JSON',
|
|
81
|
+
}[d.type as string] || 'TEXT'
|
|
82
|
+
// The reason `Field.Varchar` exists: MySQL rejects a literal DEFAULT on
|
|
83
|
+
// TEXT/BLOB/JSON, so a sized column is the only way to have both text and
|
|
84
|
+
// a default on this dialect.
|
|
85
|
+
let sql =
|
|
86
|
+
d.type === 'string' && typeof d.length === 'number'
|
|
87
|
+
? `VARCHAR(${d.length})`
|
|
88
|
+
: typeStr
|
|
89
|
+
if (d.autoIncrement && d.type === 'integer') sql += ' AUTO_INCREMENT'
|
|
90
|
+
if (d.primary) sql += ' PRIMARY KEY'
|
|
91
|
+
if (!d.nullable && !d.primary) sql += ' NOT NULL'
|
|
92
|
+
// The CHECK names the column, which is why colDef takes it. Emitted only
|
|
93
|
+
// when both are known: an ALTER path that has no name yet gets a plain
|
|
94
|
+
// sized column rather than a syntax error.
|
|
95
|
+
const check =
|
|
96
|
+
Array.isArray(d._enum) && d._enum.length && column
|
|
97
|
+
? this.enumClause(column, d._enum)
|
|
98
|
+
: ''
|
|
99
|
+
return sql + this.formatDefault(d.default, '1', '0') + check
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
override async rename(
|
|
103
|
+
type: 'TABLE' | 'COLUMN',
|
|
104
|
+
...params: string[]
|
|
105
|
+
): Promise<SQLAdapter.RunResult> {
|
|
106
|
+
if (type === 'TABLE') {
|
|
107
|
+
const [oldName, newName] = params
|
|
108
|
+
return await this.query(
|
|
109
|
+
`RENAME TABLE ${this.quote(oldName)} TO ${this.quote(newName)}`,
|
|
110
|
+
).run()
|
|
111
|
+
}
|
|
112
|
+
const [table, oldColumn, newColumn] = params
|
|
113
|
+
try {
|
|
114
|
+
return await this.query(
|
|
115
|
+
`ALTER TABLE ${this.quote(table)}` +
|
|
116
|
+
` RENAME COLUMN ${this.quote(oldColumn)}` +
|
|
117
|
+
` TO ${this.quote(newColumn)}`,
|
|
118
|
+
).run()
|
|
119
|
+
} catch {
|
|
120
|
+
const col = (await this.query(
|
|
121
|
+
'SELECT column_type AS column_type, is_nullable AS is_nullable,' +
|
|
122
|
+
' column_default AS column_default, extra AS extra' +
|
|
123
|
+
' FROM information_schema.columns' +
|
|
124
|
+
' WHERE table_schema = DATABASE()' +
|
|
125
|
+
' AND table_name = ? AND column_name = ?',
|
|
126
|
+
).get(table, oldColumn)) as any
|
|
127
|
+
const type = String(col?.column_type || 'TEXT')
|
|
128
|
+
const notNull = col?.is_nullable === 'NO' ? ' NOT NULL' : ''
|
|
129
|
+
const defSql = col?.column_default
|
|
130
|
+
? this.formatDefault(col.column_default, '1', '0')
|
|
131
|
+
: ''
|
|
132
|
+
const extraSql = col?.extra?.trim() ? ` ${col.extra.trim()}` : ''
|
|
133
|
+
return this.query(
|
|
134
|
+
`ALTER TABLE ${this.quote(table)}` +
|
|
135
|
+
` CHANGE ${this.quote(oldColumn)} ${this.quote(newColumn)}` +
|
|
136
|
+
` ${type}${notNull}${defSql}${extraSql}`,
|
|
137
|
+
).run()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
override async createIndex(
|
|
141
|
+
indexName: string,
|
|
142
|
+
tableName: string,
|
|
143
|
+
columns: string[],
|
|
144
|
+
unique = false,
|
|
145
|
+
): Promise<SQLAdapter.RunResult> {
|
|
146
|
+
return await this.query(
|
|
147
|
+
`ALTER TABLE ${this.quote(tableName)}` +
|
|
148
|
+
` ADD ${unique ? 'UNIQUE ' : ''}INDEX ${this.quote(indexName)}` +
|
|
149
|
+
` (${columns.map(c => this.quote(c)).join(', ')})`,
|
|
150
|
+
).run()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected override async preSync(tx: SQLAdapter): Promise<void> {
|
|
154
|
+
await tx.query('SET FOREIGN_KEY_CHECKS = 0').run()
|
|
155
|
+
}
|
|
156
|
+
protected override async postSync(tx: SQLAdapter): Promise<void> {
|
|
157
|
+
await tx.query('SET FOREIGN_KEY_CHECKS = 1').run()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
override async drop(
|
|
161
|
+
type: 'TABLE' | 'VIEW' | 'INDEX' | 'COLUMN',
|
|
162
|
+
...params: string[]
|
|
163
|
+
): Promise<SQLAdapter.RunResult> {
|
|
164
|
+
if (type === 'INDEX') {
|
|
165
|
+
const indexName = params[0]
|
|
166
|
+
const row = (await this.query(
|
|
167
|
+
'SELECT DISTINCT table_name AS table_name' +
|
|
168
|
+
' FROM information_schema.statistics' +
|
|
169
|
+
' WHERE index_name = ? AND table_schema = DATABASE()',
|
|
170
|
+
).get(indexName)) as SQLAdapter.TableNameRow | undefined
|
|
171
|
+
if (row?.table_name)
|
|
172
|
+
return await this.query(
|
|
173
|
+
`DROP INDEX ${this.quote(indexName)}` +
|
|
174
|
+
` ON ${this.quote(row.table_name)}`,
|
|
175
|
+
).run()
|
|
176
|
+
try {
|
|
177
|
+
return await this.query(`DROP INDEX ${this.quote(indexName)}`).run()
|
|
178
|
+
} catch (error) {
|
|
179
|
+
// A silent `changes: 0` is indistinguishable from success, so the sync
|
|
180
|
+
// plan would record the index as dropped while it is still there.
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Failed to drop index ${indexName}: ` +
|
|
183
|
+
`${(error as Error)?.message || error}`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return await super.drop(type, ...params)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async backup(keepCount = 10): Promise<SQLAdapter.BackupResult | null> {
|
|
191
|
+
if (!this.url) return null
|
|
192
|
+
const parsed = new URL(this.url)
|
|
193
|
+
const base = Try.return(
|
|
194
|
+
() => parsed.pathname.replace(/^\//, '') || 'mysql',
|
|
195
|
+
'mysql',
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return await this.spawnBackup(
|
|
199
|
+
'mysqldump',
|
|
200
|
+
fullPath => {
|
|
201
|
+
const cmd = [
|
|
202
|
+
'mysqldump',
|
|
203
|
+
`--host=${parsed.hostname || 'localhost'}`,
|
|
204
|
+
`--port=${parsed.port || '3306'}`,
|
|
205
|
+
`--user=${parsed.username || 'root'}`,
|
|
206
|
+
`--result-file=${fullPath}`,
|
|
207
|
+
base,
|
|
208
|
+
]
|
|
209
|
+
return cmd
|
|
210
|
+
},
|
|
211
|
+
'.sql',
|
|
212
|
+
keepCount,
|
|
213
|
+
base,
|
|
214
|
+
parsed.password
|
|
215
|
+
? { MYSQL_PWD: decodeURIComponent(parsed.password) }
|
|
216
|
+
: undefined,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* `ON DUPLICATE KEY UPDATE`, MySQL's upsert.
|
|
222
|
+
*
|
|
223
|
+
* It takes **no conflict target**: it fires on any unique key that collides,
|
|
224
|
+
* so `cols` is unused here. They are still required of the caller, because
|
|
225
|
+
* Postgres and SQLite cannot express the statement without them and a schema
|
|
226
|
+
* that works on one dialect should work on all three.
|
|
227
|
+
*
|
|
228
|
+
* There is no `DO NOTHING` either. Assigning a column to itself is the
|
|
229
|
+
* documented idiom for it and leaves the row untouched.
|
|
230
|
+
*/
|
|
231
|
+
override upsertClause(cols: string[], targets: string[]): string {
|
|
232
|
+
if (!targets.length) {
|
|
233
|
+
const self = this.quote(Case.snake(cols[0]!))
|
|
234
|
+
return ` ON DUPLICATE KEY UPDATE ${self} = ${self}`
|
|
235
|
+
}
|
|
236
|
+
const sets = targets.map(k => {
|
|
237
|
+
const q = this.quote(Case.snake(k))
|
|
238
|
+
return `${q} = VALUES(${q})`
|
|
239
|
+
})
|
|
240
|
+
return ` ON DUPLICATE KEY UPDATE ${sets.join(', ')}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* MySQL's `insertId` for a multi-row insert is the id of the **first** row of
|
|
245
|
+
* the block, where SQLite and Postgres report the last.
|
|
246
|
+
*/
|
|
247
|
+
override get batchInsertIdPosition(): 'first' | 'last' {
|
|
248
|
+
return 'first'
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* MySQL binds a view's tables at query time, not at `CREATE VIEW`, so a table
|
|
253
|
+
* can be dropped and rebuilt underneath a view that names it. SQLite and
|
|
254
|
+
* Postgres both refuse — see the base declaration for the two messages.
|
|
255
|
+
*/
|
|
256
|
+
override get viewsBlockTableRebuild(): boolean {
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* MySQL has no `FULL OUTER JOIN` in any version. `FULL JOIN` and
|
|
262
|
+
* `FULL OUTER JOIN` both fail the parser, and the error names nothing more
|
|
263
|
+
* specific than "error in your SQL syntax".
|
|
264
|
+
*/
|
|
265
|
+
override get supportsFullOuterJoin(): boolean {
|
|
266
|
+
return false
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
protected withConnection(sql: unknown): SQLAdapter {
|
|
270
|
+
return new MySQLAdapter(sql as SQL)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async getSchema(): Promise<SQLAdapter.TableDetails[]> {
|
|
274
|
+
const res = (await this.query(
|
|
275
|
+
'SELECT table_name AS name' +
|
|
276
|
+
' FROM information_schema.tables' +
|
|
277
|
+
' WHERE table_schema = DATABASE()' +
|
|
278
|
+
' ORDER BY table_name',
|
|
279
|
+
).all()) as SQLAdapter.NameRow[]
|
|
280
|
+
const tablesWithDetails: SQLAdapter.TableDetails[] = []
|
|
281
|
+
for (const t of res) {
|
|
282
|
+
const [countRes, cols, idxs] = (await Promise.all([
|
|
283
|
+
this.query(`SELECT COUNT(*) as count FROM ${this.quote(t.name)}`).get(),
|
|
284
|
+
this.query(
|
|
285
|
+
'SELECT column_name AS name, data_type AS type,' +
|
|
286
|
+
' is_nullable AS is_nullable, column_key AS column_key' +
|
|
287
|
+
' FROM information_schema.columns' +
|
|
288
|
+
' WHERE table_name = ? AND table_schema = DATABASE()' +
|
|
289
|
+
' ORDER BY ordinal_position',
|
|
290
|
+
).all(t.name),
|
|
291
|
+
this.query(
|
|
292
|
+
'SELECT index_name AS name, non_unique AS non_unique' +
|
|
293
|
+
' FROM information_schema.statistics' +
|
|
294
|
+
' WHERE table_name = ? AND table_schema = DATABASE()',
|
|
295
|
+
).all(t.name),
|
|
296
|
+
])) as [SQLAdapter.CountRow, any[], any[]]
|
|
297
|
+
const uniqueIdxs = Array.from(
|
|
298
|
+
new Map(idxs.map(i => [i.name, i.non_unique === 0])).entries(),
|
|
299
|
+
).map(([name, unique]) => ({ name, unique }))
|
|
300
|
+
tablesWithDetails.push({
|
|
301
|
+
name: t.name,
|
|
302
|
+
rowCount: countRes?.count || 0,
|
|
303
|
+
columns: cols.map(c => ({
|
|
304
|
+
name: c.name,
|
|
305
|
+
type: c.type,
|
|
306
|
+
notnull: c.is_nullable === 'NO',
|
|
307
|
+
pk: c.column_key === 'PRI',
|
|
308
|
+
})),
|
|
309
|
+
indexes: uniqueIdxs,
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
return tablesWithDetails
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async getData(
|
|
316
|
+
tableName: string,
|
|
317
|
+
options: SQLAdapter.TableDataOptions,
|
|
318
|
+
): Promise<SQLAdapter.TableDataResult> {
|
|
319
|
+
const cols = (await this.query(
|
|
320
|
+
'SELECT column_name AS name' +
|
|
321
|
+
' FROM information_schema.columns' +
|
|
322
|
+
' WHERE table_name = ? AND table_schema = DATABASE()',
|
|
323
|
+
).all(tableName)) as SQLAdapter.NameRow[]
|
|
324
|
+
const { whereSql, orderSql, whereParams } = this.buildFilterSort(
|
|
325
|
+
options,
|
|
326
|
+
new Set(cols.map(c => c.name)),
|
|
327
|
+
)
|
|
328
|
+
const tName = this.quote(tableName)
|
|
329
|
+
const countRes = (await this.query(
|
|
330
|
+
`SELECT COUNT(*) as count FROM ${tName}${whereSql}`,
|
|
331
|
+
).get(...whereParams)) as SQLAdapter.CountRow
|
|
332
|
+
const totalRows = countRes?.count || 0
|
|
333
|
+
const rows = await this.query(
|
|
334
|
+
`SELECT * FROM ${tName}${whereSql}${orderSql} LIMIT ? OFFSET ?`,
|
|
335
|
+
).all(
|
|
336
|
+
...whereParams,
|
|
337
|
+
options.pageSize,
|
|
338
|
+
(options.page - 1) * options.pageSize,
|
|
339
|
+
)
|
|
340
|
+
return {
|
|
341
|
+
rows,
|
|
342
|
+
totalRows,
|
|
343
|
+
page: options.page,
|
|
344
|
+
pageSize: options.pageSize,
|
|
345
|
+
totalPages: Math.ceil(totalRows / options.pageSize),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* MySQL has no rowid or ctid, so a single-row `remove`/`update` has to
|
|
351
|
+
* address the row by its primary key.
|
|
352
|
+
*
|
|
353
|
+
* This deliberately does *not* go through `getSchema()`. That lists every
|
|
354
|
+
* table in the database and then issues `COUNT(*)` plus two
|
|
355
|
+
* information_schema queries per table — so deleting one row from a
|
|
356
|
+
* twenty-table schema ran sixty-one statements, twenty of them full row
|
|
357
|
+
* counts, to learn one column name. The pk is one lookup against the same
|
|
358
|
+
* `column_key = 'PRI'` that getSchema itself derived the flag from, in the
|
|
359
|
+
* same `ordinal_position` order, so the column chosen is unchanged.
|
|
360
|
+
*/
|
|
361
|
+
private async primaryKeyOf(tableName: string): Promise<string> {
|
|
362
|
+
const rows = (await this.query(
|
|
363
|
+
'SELECT column_name AS name' +
|
|
364
|
+
' FROM information_schema.columns' +
|
|
365
|
+
' WHERE table_name = ? AND table_schema = DATABASE()' +
|
|
366
|
+
" AND column_key = 'PRI'" +
|
|
367
|
+
' ORDER BY ordinal_position',
|
|
368
|
+
).all(tableName)) as SQLAdapter.NameRow[]
|
|
369
|
+
return rows[0]?.name || 'id'
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async remove(
|
|
373
|
+
tableName: string,
|
|
374
|
+
rowid: unknown,
|
|
375
|
+
): Promise<SQLAdapter.RunResult> {
|
|
376
|
+
const pk = await this.primaryKeyOf(tableName)
|
|
377
|
+
return await this.query(
|
|
378
|
+
`DELETE FROM ${this.quote(tableName)} WHERE ${this.quote(pk)} = ?`,
|
|
379
|
+
).run(rowid)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async truncate(tableName: string): Promise<SQLAdapter.RunResult> {
|
|
383
|
+
return await this.query(`TRUNCATE TABLE ${this.quote(tableName)}`).run()
|
|
384
|
+
}
|
|
385
|
+
async update(
|
|
386
|
+
tableName: string,
|
|
387
|
+
rowid: unknown,
|
|
388
|
+
row: SQLAdapter.RowRecord,
|
|
389
|
+
): Promise<SQLAdapter.RunResult> {
|
|
390
|
+
const keys = Object.keys(row).filter(k => k !== 'rowid')
|
|
391
|
+
const pk = await this.primaryKeyOf(tableName)
|
|
392
|
+
return await this.query(
|
|
393
|
+
`UPDATE ${this.quote(tableName)}` +
|
|
394
|
+
` SET ${keys.map(k => `${this.quote(k)} = ?`).join(', ')}` +
|
|
395
|
+
` WHERE ${this.quote(pk)} = ?`,
|
|
396
|
+
).run(...keys.map(k => row[k]), rowid)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Every information_schema column is aliased to its own lowercase name, and
|
|
401
|
+
* the aliases are load-bearing despite looking redundant.
|
|
402
|
+
*
|
|
403
|
+
* MySQL 8 returns information_schema field names **uppercase** — a bare
|
|
404
|
+
* `SELECT table_name` yields a row keyed `TABLE_NAME` — while an alias is
|
|
405
|
+
* returned exactly as written. Without them `t.table_name` is `undefined`
|
|
406
|
+
* and this method throws `undefined is not an object` inside `Case.camel`,
|
|
407
|
+
* which takes `db:sync` against MySQL down entirely. Postgres was never
|
|
408
|
+
* affected because its adapter already aliased everything.
|
|
409
|
+
*/
|
|
410
|
+
|
|
411
|
+
override async getForeignKeys(): Promise<SyncTypes.DBForeignKeys> {
|
|
412
|
+
const rows = (await this.query(
|
|
413
|
+
'SELECT kcu.constraint_name AS name, kcu.table_name AS child,' +
|
|
414
|
+
' kcu.column_name AS child_col,' +
|
|
415
|
+
' kcu.referenced_table_name AS parent,' +
|
|
416
|
+
' kcu.referenced_column_name AS parent_col,' +
|
|
417
|
+
' rc.delete_rule AS on_delete, rc.update_rule AS on_update' +
|
|
418
|
+
' FROM information_schema.key_column_usage kcu' +
|
|
419
|
+
' JOIN information_schema.referential_constraints rc' +
|
|
420
|
+
' ON rc.constraint_name = kcu.constraint_name' +
|
|
421
|
+
' AND rc.constraint_schema = kcu.table_schema' +
|
|
422
|
+
' WHERE kcu.table_schema = DATABASE()' +
|
|
423
|
+
' AND kcu.referenced_table_name IS NOT NULL' +
|
|
424
|
+
' ORDER BY kcu.constraint_name, kcu.ordinal_position',
|
|
425
|
+
).all()) as any[]
|
|
426
|
+
return SQLAdapter.groupForeignKeyRows(rows)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** MySQL spells it `DROP FOREIGN KEY`, not `DROP CONSTRAINT`. */
|
|
430
|
+
override async dropForeignKey(fk: SyncTypes.ForeignKeyInfo): Promise<void> {
|
|
431
|
+
const name = fk.name || SQLAdapter.foreignKeyName(fk)
|
|
432
|
+
await this.query(
|
|
433
|
+
`ALTER TABLE ${this.quote(Case.snake(fk.table))}` +
|
|
434
|
+
` DROP FOREIGN KEY ${this.quote(name)}`,
|
|
435
|
+
).run()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async getConstraints(): Promise<SyncTypes.DBConstraints> {
|
|
439
|
+
const tables = (await this.query(
|
|
440
|
+
'SELECT table_name AS table_name, table_type AS table_type' +
|
|
441
|
+
' FROM information_schema.tables' +
|
|
442
|
+
' WHERE table_schema = DATABASE()' +
|
|
443
|
+
" AND table_type IN ('BASE TABLE','VIEW')",
|
|
444
|
+
).all()) as any[]
|
|
445
|
+
const dbConstraints: SyncTypes.DBConstraints = {}
|
|
446
|
+
|
|
447
|
+
for (const t of tables) {
|
|
448
|
+
const tName = Case.camel(t.table_name)
|
|
449
|
+
dbConstraints[tName] = {} as SyncTypes.TableConstraints
|
|
450
|
+
|
|
451
|
+
if (t.table_type === 'VIEW') {
|
|
452
|
+
const viewDef = (await this.query(
|
|
453
|
+
'SELECT view_definition AS view_definition' +
|
|
454
|
+
' FROM information_schema.views' +
|
|
455
|
+
' WHERE table_schema = DATABASE() AND table_name = ?',
|
|
456
|
+
).get(t.table_name)) as any
|
|
457
|
+
if (viewDef?.view_definition)
|
|
458
|
+
dbConstraints[tName]._view = viewDef.view_definition
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const cols = (await this.query(
|
|
462
|
+
'SELECT column_name AS column_name, column_type AS column_type,' +
|
|
463
|
+
' data_type AS data_type, is_nullable AS is_nullable,' +
|
|
464
|
+
' column_key AS column_key, column_default AS column_default,' +
|
|
465
|
+
' extra AS extra,' +
|
|
466
|
+
' character_maximum_length AS character_maximum_length' +
|
|
467
|
+
' FROM information_schema.columns' +
|
|
468
|
+
' WHERE table_schema = DATABASE() AND table_name = ?' +
|
|
469
|
+
' ORDER BY ordinal_position',
|
|
470
|
+
).all(t.table_name)) as any[]
|
|
471
|
+
|
|
472
|
+
for (const col of cols) {
|
|
473
|
+
dbConstraints[tName][Case.camel(col.column_name)] =
|
|
474
|
+
this.parseConstraints(col)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return dbConstraints
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async getIndexes(): Promise<SyncTypes.DBIndexes> {
|
|
481
|
+
const rows = (await this.query(
|
|
482
|
+
'SELECT index_name AS index_name, non_unique AS non_unique,' +
|
|
483
|
+
' table_name AS table_name, column_name AS column_name' +
|
|
484
|
+
' FROM information_schema.statistics' +
|
|
485
|
+
' WHERE table_schema = DATABASE() AND index_name IS NOT NULL' +
|
|
486
|
+
' ORDER BY index_name, seq_in_index',
|
|
487
|
+
).all()) as any[]
|
|
488
|
+
const idxMap: Record<
|
|
489
|
+
string,
|
|
490
|
+
{ table: string; cols: string[]; non_unique: number }
|
|
491
|
+
> = {}
|
|
492
|
+
for (const r of rows) {
|
|
493
|
+
if (!r.index_name || r.index_name.toUpperCase() === 'PRIMARY') continue
|
|
494
|
+
idxMap[r.index_name] ??= {
|
|
495
|
+
table: r.table_name,
|
|
496
|
+
cols: [],
|
|
497
|
+
non_unique: r.non_unique,
|
|
498
|
+
}
|
|
499
|
+
idxMap[r.index_name].cols.push(r.column_name)
|
|
500
|
+
}
|
|
501
|
+
return Object.fromEntries(
|
|
502
|
+
Object.entries(idxMap).map(([name, info]) => [
|
|
503
|
+
Case.camel(name),
|
|
504
|
+
{
|
|
505
|
+
type: info.non_unique === 0 ? 'unique' : 'index',
|
|
506
|
+
table: Case.camel(info.table),
|
|
507
|
+
cols: info.cols.map(Case.camel),
|
|
508
|
+
},
|
|
509
|
+
]),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
// Match pattern only. MySQL re-renders an expression default before storing
|
|
513
|
+
// it in information_schema (`unix_timestamp()`, case and parens not
|
|
514
|
+
// guaranteed), and isDateNowDefault() strips parens and uppercases before
|
|
515
|
+
// comparing — so the bare prefix is the right entry here. It is *not*
|
|
516
|
+
// emittable SQL; see below.
|
|
517
|
+
override readonly dateNowDefaults: string[] = ['UNIX_TIMESTAMP']
|
|
518
|
+
|
|
519
|
+
// Emitted into DDL. The call parens are load-bearing:
|
|
520
|
+
// `DEFAULT (UNIX_TIMESTAMP)` parses as a reference to a column named
|
|
521
|
+
// UNIX_TIMESTAMP (ERROR 3109), and the
|
|
522
|
+
// outer parens formatDefault adds are required for an expression default.
|
|
523
|
+
override readonly dateNowExpression: string = 'UNIX_TIMESTAMP()'
|
|
524
|
+
|
|
525
|
+
// MySQL reports an expression default back with the call parens intact, so
|
|
526
|
+
// the bare name matches either spelling. Expression defaults need 8.0.13 or
|
|
527
|
+
// newer; below that MySQL rejects everything except CURRENT_TIMESTAMP.
|
|
528
|
+
override readonly uuidDefaults: string[] = ['UUID']
|
|
529
|
+
override readonly uuidExpression: string = 'UUID()'
|
|
530
|
+
|
|
531
|
+
protected override parseConstraints(col: any): SyncTypes.ColumnConstraint {
|
|
532
|
+
const primary = col.column_key === 'PRI'
|
|
533
|
+
const cons: SyncTypes.ColumnConstraint = {
|
|
534
|
+
// column_type first, not data_type: MySQL stores a boolean as TINYINT(1)
|
|
535
|
+
// and only column_type carries the display width. Reading data_type first
|
|
536
|
+
// yields a bare 'tinyint' and makes the boolean branch unreachable.
|
|
537
|
+
type: MySQLAdapter.mapMySqlTypeToTsType(
|
|
538
|
+
String(col.column_type || col.data_type || ''),
|
|
539
|
+
),
|
|
540
|
+
}
|
|
541
|
+
// `column_type` ('varchar(64)'), not `data_type` ('varchar'), so the guard
|
|
542
|
+
// sees the same string a human would read — and so TEXT, whose
|
|
543
|
+
// character_maximum_length MySQL reports as 65535, is excluded.
|
|
544
|
+
const length = this.sizedTextLength(
|
|
545
|
+
String(col.column_type || col.data_type || ''),
|
|
546
|
+
col.character_maximum_length,
|
|
547
|
+
)
|
|
548
|
+
if (length !== undefined) cons.length = length
|
|
549
|
+
if (primary) cons.primary = true
|
|
550
|
+
if (
|
|
551
|
+
String(col.extra || '')
|
|
552
|
+
.toLowerCase()
|
|
553
|
+
.includes('auto_increment')
|
|
554
|
+
)
|
|
555
|
+
cons.autoIncrement = true
|
|
556
|
+
if (col.is_nullable === 'YES' && !primary) cons.nullable = true
|
|
557
|
+
|
|
558
|
+
let def = this.parseDefault(col.column_default)
|
|
559
|
+
if (typeof def === 'string' && def === '%dateNow%') def = `'${def}'`
|
|
560
|
+
if (def !== undefined) cons.default = def
|
|
561
|
+
return cons
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private static readonly mysqlTypes = [
|
|
565
|
+
{
|
|
566
|
+
test: (t: string) =>
|
|
567
|
+
t.includes('tinyint(1)') ||
|
|
568
|
+
t === 'bit(1)' ||
|
|
569
|
+
t === 'boolean' ||
|
|
570
|
+
t === 'bool',
|
|
571
|
+
type: 'boolean' as const,
|
|
572
|
+
},
|
|
573
|
+
{ test: (t: string) => t.includes('json'), type: 'json' as const },
|
|
574
|
+
{ test: (t: string) => t.includes('bigint'), type: 'bigint' as const },
|
|
575
|
+
{
|
|
576
|
+
test: (t: string) =>
|
|
577
|
+
t.includes('int') ||
|
|
578
|
+
t.includes('serial') ||
|
|
579
|
+
t.includes('bigint') ||
|
|
580
|
+
t.includes('smallint') ||
|
|
581
|
+
t.includes('mediumint'),
|
|
582
|
+
type: 'integer' as const,
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
test: (t: string) =>
|
|
586
|
+
t.includes('char') ||
|
|
587
|
+
t.includes('text') ||
|
|
588
|
+
t.includes('enum') ||
|
|
589
|
+
t.includes('set') ||
|
|
590
|
+
t.includes('date') ||
|
|
591
|
+
t.includes('time') ||
|
|
592
|
+
t.includes('timestamp'),
|
|
593
|
+
type: 'string' as const,
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
test: (t: string) =>
|
|
597
|
+
t.includes('blob') || t.includes('binary') || t.includes('varbinary'),
|
|
598
|
+
type: 'buffer' as const,
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
test: (t: string) =>
|
|
602
|
+
t.includes('double') ||
|
|
603
|
+
t.includes('float') ||
|
|
604
|
+
t.includes('decimal') ||
|
|
605
|
+
t.includes('numeric'),
|
|
606
|
+
type: 'number' as const,
|
|
607
|
+
},
|
|
608
|
+
]
|
|
609
|
+
|
|
610
|
+
private static mapMySqlTypeToTsType(
|
|
611
|
+
sqlType: string,
|
|
612
|
+
): SyncTypes.ColumnConstraint['type'] {
|
|
613
|
+
const t = (sqlType || '').toLowerCase()
|
|
614
|
+
for (const m of MySQLAdapter.mysqlTypes) {
|
|
615
|
+
if (m.test(t)) return m.type
|
|
616
|
+
}
|
|
617
|
+
return 'string'
|
|
618
|
+
}
|
|
619
|
+
}
|