@remix-run/data-table-mysql 0.0.0 → 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.
@@ -0,0 +1,319 @@
1
+ import type {
2
+ AdapterCapabilityOverrides,
3
+ AdapterExecuteRequest,
4
+ AdapterResult,
5
+ DatabaseAdapter,
6
+ TransactionOptions,
7
+ TransactionToken,
8
+ } from '@remix-run/data-table'
9
+ import { getTablePrimaryKey } from '@remix-run/data-table'
10
+
11
+ import { compileMysqlStatement } from './sql-compiler.ts'
12
+
13
+ /**
14
+ * Row-array response shape for mysql query calls.
15
+ */
16
+ export type MysqlQueryRows = Record<string, unknown>[]
17
+
18
+ /**
19
+ * Metadata shape for mysql write results.
20
+ */
21
+ export type MysqlQueryResultHeader = {
22
+ affectedRows: number
23
+ insertId: unknown
24
+ }
25
+
26
+ /**
27
+ * Supported mysql `query()` response tuple.
28
+ */
29
+ export type MysqlQueryResponse = [result: unknown, fields?: unknown]
30
+
31
+ /**
32
+ * Single mysql connection contract used by this adapter.
33
+ */
34
+ export type MysqlDatabaseConnection = {
35
+ query(text: string, values?: unknown[]): Promise<MysqlQueryResponse>
36
+ beginTransaction(): Promise<void>
37
+ commit(): Promise<void>
38
+ rollback(): Promise<void>
39
+ release?: () => void
40
+ }
41
+
42
+ /**
43
+ * Mysql pool contract used by this adapter.
44
+ */
45
+ export type MysqlDatabasePool = {
46
+ query(text: string, values?: unknown[]): Promise<MysqlQueryResponse>
47
+ getConnection(): Promise<MysqlDatabaseConnection>
48
+ }
49
+
50
+ /**
51
+ * Mysql adapter configuration.
52
+ */
53
+ export type MysqlDatabaseAdapterOptions = {
54
+ capabilities?: AdapterCapabilityOverrides
55
+ }
56
+
57
+ type TransactionState = {
58
+ connection: MysqlDatabaseConnection
59
+ releaseOnClose: boolean
60
+ }
61
+
62
+ type MysqlQueryable = MysqlDatabasePool | MysqlDatabaseConnection
63
+
64
+ /**
65
+ * `DatabaseAdapter` implementation for mysql-compatible clients.
66
+ */
67
+ export class MysqlDatabaseAdapter implements DatabaseAdapter {
68
+ dialect = 'mysql'
69
+ capabilities
70
+
71
+ #client: MysqlQueryable
72
+ #transactions = new Map<string, TransactionState>()
73
+ #transactionCounter = 0
74
+
75
+ constructor(client: MysqlQueryable, options?: MysqlDatabaseAdapterOptions) {
76
+ this.#client = client
77
+ this.capabilities = {
78
+ returning: options?.capabilities?.returning ?? false,
79
+ savepoints: options?.capabilities?.savepoints ?? true,
80
+ upsert: options?.capabilities?.upsert ?? true,
81
+ }
82
+ }
83
+
84
+ async execute(request: AdapterExecuteRequest): Promise<AdapterResult> {
85
+ if (request.statement.kind === 'insertMany' && request.statement.values.length === 0) {
86
+ return {
87
+ affectedRows: 0,
88
+ insertId: undefined,
89
+ rows: request.statement.returning ? [] : undefined,
90
+ }
91
+ }
92
+
93
+ let statement = compileMysqlStatement(request.statement)
94
+ let client = this.#resolveClient(request.transaction)
95
+ let [result] = await client.query(statement.text, statement.values)
96
+
97
+ if (isRowsResult(result)) {
98
+ let rows = normalizeRows(result)
99
+
100
+ if (request.statement.kind === 'count' || request.statement.kind === 'exists') {
101
+ rows = normalizeCountRows(rows)
102
+ }
103
+
104
+ return { rows }
105
+ }
106
+
107
+ let header = normalizeHeader(result)
108
+
109
+ return {
110
+ affectedRows: header.affectedRows,
111
+ insertId: normalizeInsertId(request.statement.kind, request.statement, header),
112
+ }
113
+ }
114
+
115
+ async beginTransaction(options?: TransactionOptions): Promise<TransactionToken> {
116
+ let releaseOnClose = false
117
+ let connection: MysqlDatabaseConnection
118
+
119
+ if (isMysqlPool(this.#client)) {
120
+ connection = await this.#client.getConnection()
121
+ releaseOnClose = true
122
+ } else {
123
+ connection = this.#client
124
+ }
125
+
126
+ if (options?.isolationLevel) {
127
+ await connection.query('set transaction isolation level ' + options.isolationLevel)
128
+ }
129
+
130
+ if (options?.readOnly !== undefined) {
131
+ await connection.query(
132
+ options.readOnly ? 'set transaction read only' : 'set transaction read write',
133
+ )
134
+ }
135
+
136
+ await connection.beginTransaction()
137
+
138
+ this.#transactionCounter += 1
139
+ let token = { id: 'tx_' + String(this.#transactionCounter) }
140
+
141
+ this.#transactions.set(token.id, {
142
+ connection,
143
+ releaseOnClose,
144
+ })
145
+
146
+ return token
147
+ }
148
+
149
+ async commitTransaction(token: TransactionToken): Promise<void> {
150
+ let transaction = this.#transactions.get(token.id)
151
+
152
+ if (!transaction) {
153
+ throw new Error('Unknown transaction token: ' + token.id)
154
+ }
155
+
156
+ try {
157
+ await transaction.connection.commit()
158
+ } finally {
159
+ this.#transactions.delete(token.id)
160
+
161
+ if (transaction.releaseOnClose) {
162
+ transaction.connection.release?.()
163
+ }
164
+ }
165
+ }
166
+
167
+ async rollbackTransaction(token: TransactionToken): Promise<void> {
168
+ let transaction = this.#transactions.get(token.id)
169
+
170
+ if (!transaction) {
171
+ throw new Error('Unknown transaction token: ' + token.id)
172
+ }
173
+
174
+ try {
175
+ await transaction.connection.rollback()
176
+ } finally {
177
+ this.#transactions.delete(token.id)
178
+
179
+ if (transaction.releaseOnClose) {
180
+ transaction.connection.release?.()
181
+ }
182
+ }
183
+ }
184
+
185
+ async createSavepoint(token: TransactionToken, name: string): Promise<void> {
186
+ let connection = this.#transactionConnection(token)
187
+ await connection.query('savepoint ' + quoteIdentifier(name))
188
+ }
189
+
190
+ async rollbackToSavepoint(token: TransactionToken, name: string): Promise<void> {
191
+ let connection = this.#transactionConnection(token)
192
+ await connection.query('rollback to savepoint ' + quoteIdentifier(name))
193
+ }
194
+
195
+ async releaseSavepoint(token: TransactionToken, name: string): Promise<void> {
196
+ let connection = this.#transactionConnection(token)
197
+ await connection.query('release savepoint ' + quoteIdentifier(name))
198
+ }
199
+
200
+ #resolveClient(token: TransactionToken | undefined): MysqlDatabaseConnection | MysqlDatabasePool {
201
+ if (!token) {
202
+ return this.#client
203
+ }
204
+
205
+ return this.#transactionConnection(token)
206
+ }
207
+
208
+ #transactionConnection(token: TransactionToken): MysqlDatabaseConnection {
209
+ let transaction = this.#transactions.get(token.id)
210
+
211
+ if (!transaction) {
212
+ throw new Error('Unknown transaction token: ' + token.id)
213
+ }
214
+
215
+ return transaction.connection
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Creates a mysql `DatabaseAdapter`.
221
+ * @param client Mysql pool or connection.
222
+ * @param options Optional adapter capability overrides.
223
+ * @returns A configured mysql adapter.
224
+ */
225
+ export function createMysqlDatabaseAdapter(
226
+ client: MysqlQueryable,
227
+ options?: MysqlDatabaseAdapterOptions,
228
+ ): MysqlDatabaseAdapter {
229
+ return new MysqlDatabaseAdapter(client, options)
230
+ }
231
+
232
+ function isMysqlPool(client: MysqlQueryable): client is MysqlDatabasePool {
233
+ return typeof (client as MysqlDatabasePool).getConnection === 'function'
234
+ }
235
+
236
+ function isRowsResult(result: unknown): result is MysqlQueryRows {
237
+ return Array.isArray(result) && (result.length === 0 || !Array.isArray(result[0]))
238
+ }
239
+
240
+ function normalizeRows(rows: MysqlQueryRows): Record<string, unknown>[] {
241
+ return rows.map((row) => ({ ...row }))
242
+ }
243
+
244
+ function normalizeHeader(result: unknown): MysqlQueryResultHeader {
245
+ if (typeof result === 'object' && result !== null) {
246
+ let header = result as { affectedRows?: unknown; insertId?: unknown }
247
+
248
+ return {
249
+ affectedRows: typeof header.affectedRows === 'number' ? header.affectedRows : 0,
250
+ insertId: header.insertId,
251
+ }
252
+ }
253
+
254
+ return {
255
+ affectedRows: 0,
256
+ insertId: undefined,
257
+ }
258
+ }
259
+
260
+ function normalizeCountRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
261
+ return rows.map((row) => {
262
+ let count = row.count
263
+
264
+ if (typeof count === 'string') {
265
+ let numeric = Number(count)
266
+
267
+ if (!Number.isNaN(numeric)) {
268
+ return {
269
+ ...row,
270
+ count: numeric,
271
+ }
272
+ }
273
+ }
274
+
275
+ if (typeof count === 'bigint') {
276
+ return {
277
+ ...row,
278
+ count: Number(count),
279
+ }
280
+ }
281
+
282
+ return row
283
+ })
284
+ }
285
+
286
+ function normalizeInsertId(
287
+ kind: AdapterExecuteRequest['statement']['kind'],
288
+ statement: AdapterExecuteRequest['statement'],
289
+ header: MysqlQueryResultHeader,
290
+ ): unknown {
291
+ if (!isInsertStatementKind(kind) || !isInsertStatement(statement)) {
292
+ return undefined
293
+ }
294
+
295
+ if (getTablePrimaryKey(statement.table).length !== 1) {
296
+ return undefined
297
+ }
298
+
299
+ return header.insertId
300
+ }
301
+
302
+ function quoteIdentifier(value: string): string {
303
+ return '`' + value.replace(/`/g, '``') + '`'
304
+ }
305
+
306
+ function isInsertStatementKind(kind: AdapterExecuteRequest['statement']['kind']): boolean {
307
+ return kind === 'insert' || kind === 'insertMany' || kind === 'upsert'
308
+ }
309
+
310
+ function isInsertStatement(
311
+ statement: AdapterExecuteRequest['statement'],
312
+ ): statement is Extract<
313
+ AdapterExecuteRequest['statement'],
314
+ { kind: 'insert' | 'insertMany' | 'upsert' }
315
+ > {
316
+ return (
317
+ statement.kind === 'insert' || statement.kind === 'insertMany' || statement.kind === 'upsert'
318
+ )
319
+ }