@remix-run/data-table-mysql 0.3.1 → 0.5.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,764 @@
1
+ import type {
2
+ DataManipulationOperation,
3
+ DataManipulationRequest,
4
+ DataManipulationResult,
5
+ DatabaseDriver,
6
+ SqlStatement,
7
+ TableRef,
8
+ TransactionOptions,
9
+ TransactionToken,
10
+ } from '@remix-run/data-table'
11
+ import { AsyncLocalStorage } from 'node:async_hooks'
12
+
13
+ import { getTablePrimaryKey } from '@remix-run/data-table'
14
+ import mysql from 'mysql2/promise'
15
+ import type {
16
+ Connection as MysqlConnection,
17
+ Pool as MysqlPool,
18
+ PoolConnection as MysqlPoolConnection,
19
+ PoolOptions as MysqlPoolOptions,
20
+ ResultSetHeader,
21
+ RowDataPacket,
22
+ } from 'mysql2/promise'
23
+
24
+ import { compileMysqlOperation } from './sql-compiler.ts'
25
+
26
+ type TransactionState = {
27
+ connection: MysqlTransactionConnection
28
+ releaseOnClose: boolean
29
+ }
30
+
31
+ type MysqlQueryRows = RowDataPacket[]
32
+ type MysqlQueryResultHeader = {
33
+ affectedRows: number
34
+ insertId: unknown
35
+ }
36
+ type MysqlTransactionConnection = MysqlConnection | MysqlPoolConnection
37
+ type MysqlQueryable = MysqlPool | MysqlTransactionConnection
38
+
39
+ export type MysqlDatabaseInput = string | MysqlPoolOptions | MysqlQueryable
40
+
41
+ const mysqlCapabilities = Object.freeze({
42
+ returning: false,
43
+ savepoints: true,
44
+ upsert: true,
45
+ transactionalDdl: false,
46
+ migrationLock: true,
47
+ })
48
+
49
+ /** Database creation options used when wiping a config-backed MySQL driver. */
50
+ export interface MysqlDatabaseDriverOptions {
51
+ /** Character set assigned to the recreated database. */
52
+ characterSet?: string
53
+ /** Collation assigned to the recreated database. */
54
+ collation?: string
55
+ }
56
+
57
+ /**
58
+ * MySQL database driver backed by a mysql-compatible client.
59
+ */
60
+ export class MysqlDatabaseDriver implements DatabaseDriver<'mysql'> {
61
+ /**
62
+ * The SQL dialect identifier reported by this database.
63
+ */
64
+ get dialect(): 'mysql' {
65
+ return 'mysql'
66
+ }
67
+
68
+ /**
69
+ * Feature flags describing the MySQL behaviors supported by this database.
70
+ */
71
+ get capabilities() {
72
+ return mysqlCapabilities
73
+ }
74
+
75
+ #config?: string | MysqlPoolOptions
76
+ #client: MysqlQueryable
77
+ #characterSet?: string
78
+ #collation?: string
79
+ #transactions = new Map<string, TransactionState>()
80
+ #transactionCounter = 0
81
+ #migrationLockQueue = Promise.resolve()
82
+ #migrationLockStore = new AsyncLocalStorage<boolean>()
83
+ #poolClosed = false
84
+
85
+ constructor(config: MysqlDatabaseInput, options: MysqlDatabaseDriverOptions = {}) {
86
+ if (isMysqlQueryable(config)) {
87
+ this.#client = config
88
+ } else {
89
+ this.#config = config
90
+ this.#client = createMysqlPool(config)
91
+ }
92
+
93
+ this.#characterSet = options.characterSet
94
+ this.#collation = options.collation
95
+ }
96
+
97
+ /**
98
+ * Compiles a data-manipulation operation to mysql SQL statements.
99
+ * @param operation Operation to compile.
100
+ * @returns Compiled SQL statements.
101
+ */
102
+ compileSql(operation: DataManipulationOperation): SqlStatement[] {
103
+ let compiled = compileMysqlOperation(operation)
104
+ return [{ text: compiled.text, values: compiled.values }]
105
+ }
106
+
107
+ /**
108
+ * Executes a mysql data-manipulation request.
109
+ * @param request Request to execute.
110
+ * @returns Execution result.
111
+ */
112
+ async execute(request: DataManipulationRequest): Promise<DataManipulationResult> {
113
+ if (request.operation.kind === 'insertMany' && request.operation.values.length === 0) {
114
+ return {
115
+ affectedRows: 0,
116
+ insertId: undefined,
117
+ rows: request.operation.returning ? [] : undefined,
118
+ }
119
+ }
120
+
121
+ let statements = this.compileSql(request.operation)
122
+ let statement = statements[0]
123
+ let client = this.#resolveClient(request.transaction)
124
+ let [result] = await client.query(statement.text, statement.values)
125
+
126
+ if (isRowsResult(result)) {
127
+ let rows = normalizeRows(result)
128
+
129
+ if (request.operation.kind === 'count' || request.operation.kind === 'exists') {
130
+ rows = normalizeCountRows(rows)
131
+ }
132
+
133
+ return { rows }
134
+ }
135
+
136
+ let header = normalizeHeader(result)
137
+
138
+ return {
139
+ affectedRows: header.affectedRows,
140
+ insertId: normalizeInsertId(request.operation.kind, request.operation, header),
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Executes a multi-statement mysql SQL script.
146
+ *
147
+ * mysql2 only accepts multi-statement scripts when the underlying connection
148
+ * was created with `multipleStatements: true`.
149
+ * @param sql SQL script to execute.
150
+ * @param transaction Optional transaction token.
151
+ * @returns A promise that resolves once execution completes.
152
+ */
153
+ async executeScript(sql: string, transaction?: TransactionToken): Promise<void> {
154
+ let client = this.#resolveClient(transaction)
155
+ await client.query(sql)
156
+ }
157
+
158
+ /**
159
+ * Checks whether a table exists in mysql.
160
+ * @param table Table reference to inspect.
161
+ * @param transaction Optional transaction token.
162
+ * @returns `true` when the table exists.
163
+ */
164
+ async hasTable(table: TableRef, transaction?: TransactionToken): Promise<boolean> {
165
+ let schema = table.schema
166
+ let sql = schema
167
+ ? 'select exists(select 1 from information_schema.tables where table_schema = ? and table_name = ?) as `exists`'
168
+ : 'select exists(select 1 from information_schema.tables where table_schema = database() and table_name = ?) as `exists`'
169
+ let values = schema ? [schema, table.name] : [table.name]
170
+ let client = this.#resolveClient(transaction)
171
+ let [result] = await client.query(sql, values)
172
+
173
+ if (!isRowsResult(result)) {
174
+ return false
175
+ }
176
+
177
+ return toBooleanExists(result[0]?.exists)
178
+ }
179
+
180
+ /**
181
+ * Checks whether a column exists in mysql.
182
+ * @param table Table reference to inspect.
183
+ * @param column Column name to look up.
184
+ * @param transaction Optional transaction token.
185
+ * @returns `true` when the column exists.
186
+ */
187
+ async hasColumn(
188
+ table: TableRef,
189
+ column: string,
190
+ transaction?: TransactionToken,
191
+ ): Promise<boolean> {
192
+ let schema = table.schema
193
+ let sql = schema
194
+ ? 'select exists(select 1 from information_schema.columns where table_schema = ? and table_name = ? and column_name = ?) as `exists`'
195
+ : 'select exists(select 1 from information_schema.columns where table_schema = database() and table_name = ? and column_name = ?) as `exists`'
196
+ let values = schema ? [schema, table.name, column] : [table.name, column]
197
+ let client = this.#resolveClient(transaction)
198
+ let [result] = await client.query(sql, values)
199
+
200
+ if (!isRowsResult(result)) {
201
+ return false
202
+ }
203
+
204
+ return toBooleanExists(result[0]?.exists)
205
+ }
206
+
207
+ /**
208
+ * Starts a mysql transaction.
209
+ * @param options Transaction options.
210
+ * @returns Transaction token.
211
+ */
212
+ async beginTransaction(options?: TransactionOptions): Promise<TransactionToken> {
213
+ let releaseOnClose = false
214
+ let connection: MysqlTransactionConnection
215
+
216
+ if (isMysqlPool(this.#client)) {
217
+ connection = await this.#client.getConnection()
218
+ releaseOnClose = true
219
+ } else {
220
+ connection = this.#client
221
+ }
222
+
223
+ try {
224
+ if (options?.isolationLevel) {
225
+ await connection.query('set transaction isolation level ' + options.isolationLevel)
226
+ }
227
+
228
+ if (options?.readOnly !== undefined) {
229
+ await connection.query(
230
+ options.readOnly ? 'set transaction read only' : 'set transaction read write',
231
+ )
232
+ }
233
+
234
+ await connection.beginTransaction()
235
+ } catch (error) {
236
+ if (releaseOnClose) {
237
+ destroyMysqlConnection(connection)
238
+ }
239
+ throw error
240
+ }
241
+
242
+ this.#transactionCounter += 1
243
+ let token = { id: 'tx_' + String(this.#transactionCounter) }
244
+
245
+ this.#transactions.set(token.id, {
246
+ connection,
247
+ releaseOnClose,
248
+ })
249
+
250
+ return token
251
+ }
252
+
253
+ /**
254
+ * Commits an open mysql transaction.
255
+ * @param token Transaction token to commit.
256
+ * @returns A promise that resolves when the transaction is committed.
257
+ */
258
+ async commitTransaction(token: TransactionToken): Promise<void> {
259
+ let transaction = this.#transactions.get(token.id)
260
+
261
+ if (!transaction) {
262
+ throw new Error('Unknown transaction token: ' + token.id)
263
+ }
264
+
265
+ let failed = false
266
+ try {
267
+ await transaction.connection.commit()
268
+ } catch (error) {
269
+ failed = true
270
+ throw error
271
+ } finally {
272
+ this.#transactions.delete(token.id)
273
+
274
+ if (transaction.releaseOnClose) {
275
+ if (failed) {
276
+ destroyMysqlConnection(transaction.connection)
277
+ } else if (isMysqlPoolConnection(transaction.connection)) {
278
+ transaction.connection.release()
279
+ }
280
+ }
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Rolls back an open mysql transaction.
286
+ * @param token Transaction token to roll back.
287
+ * @returns A promise that resolves when the transaction is rolled back.
288
+ */
289
+ async rollbackTransaction(token: TransactionToken): Promise<void> {
290
+ let transaction = this.#transactions.get(token.id)
291
+
292
+ if (!transaction) {
293
+ throw new Error('Unknown transaction token: ' + token.id)
294
+ }
295
+
296
+ let failed = false
297
+ try {
298
+ await transaction.connection.rollback()
299
+ } catch (error) {
300
+ failed = true
301
+ throw error
302
+ } finally {
303
+ this.#transactions.delete(token.id)
304
+
305
+ if (transaction.releaseOnClose) {
306
+ if (failed) {
307
+ destroyMysqlConnection(transaction.connection)
308
+ } else if (isMysqlPoolConnection(transaction.connection)) {
309
+ transaction.connection.release()
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Creates a savepoint in an open mysql transaction.
317
+ * @param token Transaction token to use.
318
+ * @param name Savepoint name.
319
+ * @returns A promise that resolves when the savepoint is created.
320
+ */
321
+ async createSavepoint(token: TransactionToken, name: string): Promise<void> {
322
+ let connection = this.#transactionConnection(token)
323
+ await connection.query('savepoint ' + quoteIdentifier(name))
324
+ }
325
+
326
+ /**
327
+ * Rolls back to a savepoint in an open mysql transaction.
328
+ * @param token Transaction token to use.
329
+ * @param name Savepoint name.
330
+ * @returns A promise that resolves when the rollback completes.
331
+ */
332
+ async rollbackToSavepoint(token: TransactionToken, name: string): Promise<void> {
333
+ let connection = this.#transactionConnection(token)
334
+ await connection.query('rollback to savepoint ' + quoteIdentifier(name))
335
+ }
336
+
337
+ /**
338
+ * Releases a savepoint in an open mysql transaction.
339
+ * @param token Transaction token to use.
340
+ * @param name Savepoint name.
341
+ * @returns A promise that resolves when the savepoint is released.
342
+ */
343
+ async releaseSavepoint(token: TransactionToken, name: string): Promise<void> {
344
+ let connection = this.#transactionConnection(token)
345
+ await connection.query('release savepoint ' + quoteIdentifier(name))
346
+ }
347
+
348
+ /**
349
+ * Destructively recreates the configured MySQL database.
350
+ * @returns A promise that resolves when the database is ready for use.
351
+ */
352
+ async wipe(): Promise<void> {
353
+ let config = this.#configOrThrow('wipe')
354
+ this.#assertNoOpenTransactions('wipe')
355
+ let database = resolveMysqlDatabaseName(config)
356
+ await this.#closePool()
357
+ let connection: MysqlConnection | undefined
358
+
359
+ try {
360
+ connection = await createMysqlConnection(toMysqlServerConfig(config))
361
+ await connection.query('drop database if exists ' + quoteIdentifier(database))
362
+
363
+ let sql = 'create database ' + quoteIdentifier(database)
364
+
365
+ if (this.#characterSet) {
366
+ sql += ' character set ' + quoteIdentifier(this.#characterSet)
367
+ }
368
+
369
+ if (this.#collation) {
370
+ sql += ' collate ' + quoteIdentifier(this.#collation)
371
+ }
372
+
373
+ await connection.query(sql)
374
+ } finally {
375
+ try {
376
+ await connection?.end()
377
+ } finally {
378
+ await this.#replacePool()
379
+ }
380
+ }
381
+ }
382
+
383
+ /** Closes a pool created from configuration. Supplied connections and pools remain caller-owned. */
384
+ async close(): Promise<void> {
385
+ this.#assertNoOpenTransactions('close')
386
+ if (this.#config) {
387
+ await this.#closePool()
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Runs migration work on the mysql connection that owns the named lock.
393
+ *
394
+ * Lock acquisition waits up to 60 seconds and throws when the lock cannot
395
+ * be acquired. Re-entering this method from inside `run` throws instead of
396
+ * deadlocking, and a failed run destroys the reserved connection instead of
397
+ * returning it to the pool.
398
+ * @param name Logical migration lock name.
399
+ * @param run Migration work to run with a connection-bound driver.
400
+ * @returns The callback result.
401
+ */
402
+ async withMigrationLock<result>(
403
+ name: string,
404
+ run: (driver: DatabaseDriver<'mysql'>) => Promise<result>,
405
+ ): Promise<result> {
406
+ if (this.#migrationLockStore.getStore()) {
407
+ throw new Error('MySQL migration lock is already held by this database')
408
+ }
409
+
410
+ let waitForPreviousLock = this.#migrationLockQueue
411
+ let releaseQueue: () => void = () => undefined
412
+ this.#migrationLockQueue = new Promise((resolve) => {
413
+ releaseQueue = resolve
414
+ })
415
+
416
+ await waitForPreviousLock
417
+
418
+ try {
419
+ let releaseOnClose = false
420
+ let connection: MysqlTransactionConnection
421
+
422
+ if (isMysqlPool(this.#client)) {
423
+ connection = await this.#client.getConnection()
424
+ releaseOnClose = true
425
+ } else {
426
+ connection = this.#client
427
+ }
428
+
429
+ let driver = releaseOnClose ? new MysqlDatabaseDriver(connection) : this
430
+
431
+ try {
432
+ let value = await this.#migrationLockStore.run(true, () =>
433
+ runWithMysqlMigrationLock(connection, name, driver, run),
434
+ )
435
+
436
+ if (releaseOnClose && isMysqlPoolConnection(connection)) {
437
+ connection.release()
438
+ }
439
+
440
+ return value
441
+ } catch (error) {
442
+ // A failed run can leave the reserved connection dirty (open
443
+ // transaction, still-held named lock), so destroy the connection
444
+ // instead of returning it to the pool.
445
+ if (releaseOnClose) {
446
+ destroyMysqlConnection(connection)
447
+ }
448
+
449
+ throw error
450
+ }
451
+ } finally {
452
+ releaseQueue()
453
+ }
454
+ }
455
+
456
+ async #closePool(): Promise<void> {
457
+ this.#transactions.clear()
458
+ // mysql2 pools error on end() when called twice, so ending must be
459
+ // tracked to keep close() idempotent.
460
+ if (isMysqlPool(this.#client) && !this.#poolClosed) {
461
+ this.#poolClosed = true
462
+ await this.#client.end()
463
+ }
464
+ }
465
+
466
+ async #replacePool(): Promise<void> {
467
+ await this.#closePool().catch(() => undefined)
468
+
469
+ if (this.#config) {
470
+ this.#client = createMysqlPool(this.#config)
471
+ this.#poolClosed = false
472
+ }
473
+ }
474
+
475
+ #configOrThrow(method: string): string | MysqlPoolOptions {
476
+ if (!this.#config) {
477
+ throw new Error('MySQL database ' + method + '() requires config-based construction')
478
+ }
479
+
480
+ return this.#config
481
+ }
482
+
483
+ #assertNoOpenTransactions(method: string): void {
484
+ if (this.#transactions.size > 0) {
485
+ throw new Error('MySQL database cannot ' + method + ' while transactions are open')
486
+ }
487
+ }
488
+
489
+ #resolveClient(token: TransactionToken | undefined): MysqlQueryable {
490
+ if (!token) {
491
+ return this.#client
492
+ }
493
+
494
+ return this.#transactionConnection(token)
495
+ }
496
+
497
+ #transactionConnection(token: TransactionToken): MysqlTransactionConnection {
498
+ let transaction = this.#transactions.get(token.id)
499
+
500
+ if (!transaction) {
501
+ throw new Error('Unknown transaction token: ' + token.id)
502
+ }
503
+
504
+ return transaction.connection
505
+ }
506
+ }
507
+
508
+ function isMysqlQueryable(value: unknown): value is MysqlQueryable {
509
+ return typeof value === 'object' && value !== null && 'query' in value
510
+ }
511
+
512
+ function createMysqlPool(config: string | MysqlPoolOptions): MysqlPool {
513
+ return typeof config === 'string' ? mysql.createPool(config) : mysql.createPool(config)
514
+ }
515
+
516
+ function createMysqlConnection(config: string | MysqlPoolOptions): Promise<MysqlConnection> {
517
+ return typeof config === 'string'
518
+ ? mysql.createConnection(config)
519
+ : mysql.createConnection(config)
520
+ }
521
+
522
+ function isMysqlPool(client: MysqlQueryable): client is MysqlPool {
523
+ return 'getConnection' in client && typeof client.getConnection === 'function'
524
+ }
525
+
526
+ function resolveMysqlDatabaseName(config: string | MysqlPoolOptions): string {
527
+ let database =
528
+ typeof config === 'string'
529
+ ? resolveDatabaseNameFromUrl(config)
530
+ : (config.database ?? resolveDatabaseNameFromUrl(config.uri ?? ''))
531
+
532
+ if (!database) {
533
+ throw new Error('MySQL database config requires a database name')
534
+ }
535
+
536
+ return database
537
+ }
538
+
539
+ function resolveDatabaseNameFromUrl(value: string): string | undefined {
540
+ try {
541
+ let url = new URL(value)
542
+ let database = decodeURIComponent(url.pathname.replace(/^\//, ''))
543
+ return database || undefined
544
+ } catch {
545
+ return undefined
546
+ }
547
+ }
548
+
549
+ function toMysqlServerConfig(config: string | MysqlPoolOptions): string | MysqlPoolOptions {
550
+ if (typeof config === 'string') {
551
+ try {
552
+ let url = new URL(config)
553
+ url.pathname = '/'
554
+ return url.toString()
555
+ } catch {
556
+ return config
557
+ }
558
+ }
559
+
560
+ // Pool-only options make mysql2's createConnection() log "Ignoring invalid
561
+ // configuration option" warnings, so strip them from the maintenance
562
+ // connection config.
563
+ let {
564
+ database: _database,
565
+ uri,
566
+ connectionLimit: _connectionLimit,
567
+ maxIdle: _maxIdle,
568
+ idleTimeout: _idleTimeout,
569
+ queueLimit: _queueLimit,
570
+ waitForConnections: _waitForConnections,
571
+ ...serverConfig
572
+ } = config
573
+
574
+ if (uri === undefined) {
575
+ return serverConfig
576
+ }
577
+
578
+ return { ...serverConfig, uri: removeDatabaseFromUrl(uri) }
579
+ }
580
+
581
+ function removeDatabaseFromUrl(value: string): string {
582
+ try {
583
+ let url = new URL(value)
584
+ url.pathname = '/'
585
+ return url.toString()
586
+ } catch {
587
+ return value
588
+ }
589
+ }
590
+
591
+ function isMysqlPoolConnection(
592
+ connection: MysqlTransactionConnection,
593
+ ): connection is MysqlPoolConnection {
594
+ return 'release' in connection && typeof connection.release === 'function'
595
+ }
596
+
597
+ function destroyMysqlConnection(connection: MysqlTransactionConnection): void {
598
+ let destroy = (connection as { destroy?: () => void }).destroy
599
+
600
+ if (typeof destroy === 'function') {
601
+ destroy.call(connection)
602
+ return
603
+ }
604
+
605
+ void connection.end().catch(() => undefined)
606
+ }
607
+
608
+ async function runWithMysqlMigrationLock<result>(
609
+ connection: MysqlTransactionConnection,
610
+ name: string,
611
+ driver: MysqlDatabaseDriver,
612
+ run: (driver: DatabaseDriver<'mysql'>) => Promise<result>,
613
+ ): Promise<result> {
614
+ // sha2(..., 256) yields 64 hex characters, exactly GET_LOCK's 64-character
615
+ // lock name limit, so any additional prefix must go inside the hash input.
616
+ let [lockRows] = await connection.query(
617
+ "select lock_name, get_lock(lock_name, 60) as `acquired` from (select sha2(concat(coalesce(database(), ''), ':', ?), 256) as lock_name) as migration_lock",
618
+ [name],
619
+ )
620
+
621
+ let lockRow = isRowsResult(lockRows) ? lockRows[0] : undefined
622
+ let lockName = lockRow?.lock_name
623
+
624
+ if (typeof lockName !== 'string' || !toBooleanExists(lockRow?.acquired)) {
625
+ throw new Error('MySQL migration lock could not be acquired')
626
+ }
627
+
628
+ let outcome: { status: 'success'; value: result } | { status: 'failure'; error: unknown }
629
+
630
+ try {
631
+ outcome = { status: 'success', value: await run(driver) }
632
+ } catch (error) {
633
+ outcome = { status: 'failure', error }
634
+ }
635
+
636
+ let unlockFailed = false
637
+ let unlockError: unknown
638
+
639
+ try {
640
+ let [unlockRows] = await connection.query('select release_lock(?) as `released`', [lockName])
641
+
642
+ if (!isRowsResult(unlockRows) || !toBooleanExists(unlockRows[0]?.released)) {
643
+ throw new Error('MySQL migration lock was not held by the reserved connection')
644
+ }
645
+ } catch (error) {
646
+ unlockFailed = true
647
+ unlockError = error
648
+ }
649
+
650
+ if (outcome.status === 'failure') {
651
+ throw outcome.error
652
+ }
653
+
654
+ if (unlockFailed) {
655
+ throw unlockError
656
+ }
657
+
658
+ return outcome.value
659
+ }
660
+
661
+ function isRowsResult(result: unknown): result is MysqlQueryRows {
662
+ return Array.isArray(result) && (result.length === 0 || !Array.isArray(result[0]))
663
+ }
664
+
665
+ function toBooleanExists(value: unknown): boolean {
666
+ if (typeof value === 'boolean') {
667
+ return value
668
+ }
669
+
670
+ if (typeof value === 'number') {
671
+ return value > 0
672
+ }
673
+
674
+ if (typeof value === 'bigint') {
675
+ return value > 0n
676
+ }
677
+
678
+ if (typeof value === 'string') {
679
+ return value === '1' || value.toLowerCase() === 'true'
680
+ }
681
+
682
+ return false
683
+ }
684
+
685
+ function normalizeRows(rows: MysqlQueryRows): Record<string, unknown>[] {
686
+ return rows.map((row) => ({ ...row }))
687
+ }
688
+
689
+ function normalizeHeader(result: unknown): MysqlQueryResultHeader {
690
+ if (typeof result === 'object' && result !== null) {
691
+ let header = result as Partial<ResultSetHeader>
692
+
693
+ return {
694
+ affectedRows: typeof header.affectedRows === 'number' ? header.affectedRows : 0,
695
+ insertId: header.insertId,
696
+ }
697
+ }
698
+
699
+ return {
700
+ affectedRows: 0,
701
+ insertId: undefined,
702
+ }
703
+ }
704
+
705
+ function normalizeCountRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
706
+ return rows.map((row) => {
707
+ let count = row.count
708
+
709
+ if (typeof count === 'string') {
710
+ let numeric = Number(count)
711
+
712
+ if (!Number.isNaN(numeric)) {
713
+ return {
714
+ ...row,
715
+ count: numeric,
716
+ }
717
+ }
718
+ }
719
+
720
+ if (typeof count === 'bigint') {
721
+ return {
722
+ ...row,
723
+ count: Number(count),
724
+ }
725
+ }
726
+
727
+ return row
728
+ })
729
+ }
730
+
731
+ function normalizeInsertId(
732
+ kind: DataManipulationRequest['operation']['kind'],
733
+ operation: DataManipulationRequest['operation'],
734
+ header: MysqlQueryResultHeader,
735
+ ): unknown {
736
+ if (!isInsertOperationKind(kind) || !isInsertOperation(operation)) {
737
+ return undefined
738
+ }
739
+
740
+ if (getTablePrimaryKey(operation.table).length !== 1) {
741
+ return undefined
742
+ }
743
+
744
+ return header.insertId
745
+ }
746
+
747
+ function quoteIdentifier(value: string): string {
748
+ return '`' + value.replace(/`/g, '``') + '`'
749
+ }
750
+
751
+ function isInsertOperationKind(kind: DataManipulationRequest['operation']['kind']): boolean {
752
+ return kind === 'insert' || kind === 'insertMany' || kind === 'upsert'
753
+ }
754
+
755
+ function isInsertOperation(
756
+ operation: DataManipulationRequest['operation'],
757
+ ): operation is Extract<
758
+ DataManipulationRequest['operation'],
759
+ { kind: 'insert' | 'insertMany' | 'upsert' }
760
+ > {
761
+ return (
762
+ operation.kind === 'insert' || operation.kind === 'insertMany' || operation.kind === 'upsert'
763
+ )
764
+ }