@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.
@@ -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
+ }