feat: add src/db/adapter/mysql.ts
swadhinbiswas committed 8 months ago
bc6f2b2
1 parent bbd7a22
commit bc6f2b2
1 file changed
+385 -0
Showing 1 changed file+385-0
▼src/db/adapter/mysql.ts
+385-0
| @@ -0,0 +1,385 @@ | ||
1 | +/** | |
2 | + * MySQL-compatible Database Adapter | |
3 | + * Supports MySQL/MariaDB/PlanetScale/TiDB via mysql2 | |
4 | + */ | |
5 | + | |
6 | +import { | |
7 | + BaseDatabaseAdapter, | |
8 | + ColumnType, | |
9 | + DatabaseConfig, | |
10 | + Migration, | |
11 | + MigrationStatus, | |
12 | + QueryOptions, | |
13 | + QueryResult, | |
14 | + TableAlterCommand, | |
15 | + TableSchema, | |
16 | +} from "./types"; | |
17 | + | |
18 | +export class MySQLAdapter extends BaseDatabaseAdapter { | |
19 | + private pool: any; | |
20 | + private inTransaction = false; | |
21 | + private transactionClient: any = null; | |
22 | + | |
23 | + constructor(config: DatabaseConfig) { | |
24 | + super(config); | |
25 | + } | |
26 | + | |
27 | + async connect(): Promise<void> { | |
28 | + if (this.connected) return; | |
29 | + | |
30 | + try { | |
31 | + const mysql = await import("mysql2/promise"); | |
32 | + | |
33 | + this.pool = mysql.createPool({ | |
34 | + uri: this.config.url, | |
35 | + user: this.config.user, | |
36 | + password: this.config.password, | |
37 | + database: this.config.database, | |
38 | + host: this.config.host, | |
39 | + port: this.config.port, | |
40 | + ssl: this.config.ssl ? { rejectUnauthorized: false } : undefined, | |
41 | + waitForConnections: true, | |
42 | + connectionLimit: this.config.poolSize || 10, | |
43 | + }); | |
44 | + | |
45 | + await this.pool.query("SELECT 1"); | |
46 | + this.connected = true; | |
47 | + } catch (error) { | |
48 | + throw new Error(`Failed to connect to MySQL database: ${error}`); | |
49 | + } | |
50 | + } | |
51 | + | |
52 | + async disconnect(): Promise<void> { | |
53 | + if (!this.connected) return; | |
54 | + await this.pool?.end(); | |
55 | + this.connected = false; | |
56 | + } | |
57 | + | |
58 | + private getClient() { | |
59 | + return this.transactionClient || this.pool; | |
60 | + } | |
61 | + | |
62 | + async findOne<T>(table: string, options?: QueryOptions): Promise<T | null> { | |
63 | + const results = await this.findMany<T>(table, { ...options, limit: 1 }); | |
64 | + return results[0] || null; | |
65 | + } | |
66 | + | |
67 | + async findMany<T>(table: string, options?: QueryOptions): Promise<T[]> { | |
68 | + const selectClause = options?.select?.length | |
69 | + ? options.select.map((s) => `\`${s}\``).join(", ") | |
70 | + : "*"; | |
71 | + | |
72 | + if (!/^[a-zA-Z0-9_]+$/.test(table)) throw new Error("Invalid table name"); | |
73 | + | |
74 | + let sql = `SELECT ${selectClause} FROM \`${table}\``; | |
75 | + const params: unknown[] = []; | |
76 | + | |
77 | + if (options?.where?.length) { | |
78 | + const whereResult = this.buildWhereConditions(options.where); | |
79 | + sql += ` ${whereResult.sql}`; | |
80 | + params.push(...whereResult.params); | |
81 | + } | |
82 | + | |
83 | + if (options?.orderBy?.length) { | |
84 | + const orderClauses = options.orderBy.map( | |
85 | + (o) => `\`${o.field}\` ${o.direction.toUpperCase()}` | |
86 | + ); | |
87 | + sql += ` ORDER BY ${orderClauses.join(", ")}`; | |
88 | + } | |
89 | + | |
90 | + if (options?.limit) { | |
91 | + sql += " LIMIT ?"; | |
92 | + params.push(options.limit); | |
93 | + } | |
94 | + | |
95 | + if (options?.offset) { | |
96 | + sql += " OFFSET ?"; | |
97 | + params.push(options.offset); | |
98 | + } | |
99 | + | |
100 | + const [rows] = await this.getClient().query(sql, params); | |
101 | + return rows as T[]; | |
102 | + } | |
103 | + | |
104 | + async create<T>(table: string, data: Partial<T>): Promise<T> { | |
105 | + const keys = Object.keys(data); | |
106 | + const values = Object.values(data); | |
107 | + const placeholders = keys.map(() => "?").join(", "); | |
108 | + const columns = keys.map((k) => `\`${k}\``).join(", "); | |
109 | + | |
110 | + const sql = `INSERT INTO \`${table}\` (${columns}) VALUES (${placeholders})`; | |
111 | + const [result] = await this.getClient().query(sql, values); | |
112 | + const insertId = result?.insertId; | |
113 | + | |
114 | + if (insertId === undefined || insertId === null) { | |
115 | + return data as T; | |
116 | + } | |
117 | + const created = await this.findById<T>(table, insertId); | |
118 | + return (created || (data as T)); | |
119 | + } | |
120 | + | |
121 | + async createMany<T>(table: string, data: Partial<T>[]): Promise<T[]> { | |
122 | + if (data.length === 0) return []; | |
123 | + | |
124 | + const results: T[] = []; | |
125 | + for (const item of data) { | |
126 | + const created = await this.create<T>(table, item); | |
127 | + results.push(created); | |
128 | + } | |
129 | + return results; | |
130 | + } | |
131 | + | |
132 | + async update<T>( | |
133 | + table: string, | |
134 | + id: string | number, | |
135 | + data: Partial<T> | |
136 | + ): Promise<T | null> { | |
137 | + const keys = Object.keys(data); | |
138 | + const values = Object.values(data); | |
139 | + const setClause = keys.map((k) => `\`${k}\` = ?`).join(", "); | |
140 | + | |
141 | + const sql = `UPDATE \`${table}\` SET ${setClause} WHERE id = ?`; | |
142 | + const [result] = await this.getClient().query(sql, [...values, id]); | |
143 | + | |
144 | + if (!result?.affectedRows) return null; | |
145 | + return this.findById<T>(table, id); | |
146 | + } | |
147 | + | |
148 | + async updateMany<T>( | |
149 | + table: string, | |
150 | + options: QueryOptions, | |
151 | + data: Partial<T> | |
152 | + ): Promise<number> { | |
153 | + const keys = Object.keys(data); | |
154 | + const values = Object.values(data); | |
155 | + const setClause = keys.map((k) => `\`${k}\` = ?`).join(", "); | |
156 | + | |
157 | + let sql = `UPDATE \`${table}\` SET ${setClause}`; | |
158 | + const params: unknown[] = [...values]; | |
159 | + | |
160 | + if (options?.where?.length) { | |
161 | + const whereResult = this.buildWhereConditions(options.where); | |
162 | + sql += ` ${whereResult.sql}`; | |
163 | + params.push(...whereResult.params); | |
164 | + } | |
165 | + | |
166 | + const [result] = await this.getClient().query(sql, params); | |
167 | + return result?.affectedRows || 0; | |
168 | + } | |
169 | + | |
170 | + async delete(table: string, id: string | number): Promise<boolean> { | |
171 | + const sql = `DELETE FROM \`${table}\` WHERE id = ?`; | |
172 | + const [result] = await this.getClient().query(sql, [id]); | |
173 | + return (result?.affectedRows || 0) > 0; | |
174 | + } | |
175 | + | |
176 | + async deleteMany(table: string, options?: QueryOptions): Promise<number> { | |
177 | + let sql = `DELETE FROM \`${table}\``; | |
178 | + const params: unknown[] = []; | |
179 | + | |
180 | + if (options?.where?.length) { | |
181 | + const whereResult = this.buildWhereConditions(options.where); | |
182 | + sql += ` ${whereResult.sql}`; | |
183 | + params.push(...whereResult.params); | |
184 | + } | |
185 | + | |
186 | + const [result] = await this.getClient().query(sql, params); | |
187 | + return result?.affectedRows || 0; | |
188 | + } | |
189 | + | |
190 | + async count(table: string, options?: QueryOptions): Promise<number> { | |
191 | + let sql = `SELECT COUNT(*) as count FROM \`${table}\``; | |
192 | + const params: unknown[] = []; | |
193 | + | |
194 | + if (options?.where?.length) { | |
195 | + const whereResult = this.buildWhereConditions(options.where); | |
196 | + sql += ` ${whereResult.sql}`; | |
197 | + params.push(...whereResult.params); | |
198 | + } | |
199 | + | |
200 | + const [rows] = await this.getClient().query(sql, params); | |
201 | + return Number((rows?.[0] as { count?: number })?.count || 0); | |
202 | + } | |
203 | + | |
204 | + async rawQuery<T>(query: string, params?: unknown[]): Promise<QueryResult<T>> { | |
205 | + const [rows] = await this.getClient().query(query, params || []); | |
206 | + | |
207 | + if (Array.isArray(rows)) { | |
208 | + return { | |
209 | + rows: rows as T[], | |
210 | + rowCount: rows.length, | |
211 | + }; | |
212 | + } | |
213 | + | |
214 | + return { | |
215 | + rows: [], | |
216 | + rowCount: rows?.affectedRows || 0, | |
217 | + lastInsertId: rows?.insertId, | |
218 | + affectedRows: rows?.affectedRows || 0, | |
219 | + }; | |
220 | + } | |
221 | + | |
222 | + async beginTransaction(): Promise<void> { | |
223 | + if (this.inTransaction) return; | |
224 | + this.transactionClient = await this.pool.getConnection(); | |
225 | + await this.transactionClient.beginTransaction(); | |
226 | + this.inTransaction = true; | |
227 | + } | |
228 | + | |
229 | + async commitTransaction(): Promise<void> { | |
230 | + if (!this.inTransaction) return; | |
231 | + await this.transactionClient.commit(); | |
232 | + this.transactionClient.release(); | |
233 | + this.transactionClient = null; | |
234 | + this.inTransaction = false; | |
235 | + } | |
236 | + | |
237 | + async rollbackTransaction(): Promise<void> { | |
238 | + if (!this.inTransaction) return; | |
239 | + await this.transactionClient.rollback(); | |
240 | + this.transactionClient.release(); | |
241 | + this.transactionClient = null; | |
242 | + this.inTransaction = false; | |
243 | + } | |
244 | + | |
245 | + async tableExists(table: string): Promise<boolean> { | |
246 | + const [rows] = await this.getClient().query( | |
247 | + "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = ? LIMIT 1", | |
248 | + [table] | |
249 | + ); | |
250 | + return Array.isArray(rows) && rows.length > 0; | |
251 | + } | |
252 | + | |
253 | + async createTable(table: string, schema: TableSchema): Promise<void> { | |
254 | + const columnDefs = schema.columns.map((col) => { | |
255 | + let def = `\`${col.name}\` ${this.mapColumnType(col.type)}`; | |
256 | + | |
257 | + if (col.autoIncrement) def += " AUTO_INCREMENT"; | |
258 | + if (!col.nullable) def += " NOT NULL"; | |
259 | + if (col.unique) def += " UNIQUE"; | |
260 | + if (col.defaultValue !== undefined && !col.autoIncrement) { | |
261 | + def += ` DEFAULT ${this.formatDefaultValue(col.defaultValue)}`; | |
262 | + } | |
263 | + | |
264 | + return def; | |
265 | + }); | |
266 | + | |
267 | + if (schema.primaryKey) { | |
268 | + const pk = Array.isArray(schema.primaryKey) | |
269 | + ? schema.primaryKey.map((c) => `\`${c}\``).join(", ") | |
270 | + : `\`${schema.primaryKey}\``; | |
271 | + columnDefs.push(`PRIMARY KEY (${pk})`); | |
272 | + } | |
273 | + | |
274 | + const sql = `CREATE TABLE IF NOT EXISTS \`${table}\` (\n ${columnDefs.join(",\n ")}\n)`; | |
275 | + await this.getClient().query(sql); | |
276 | + } | |
277 | + | |
278 | + async dropTable(table: string): Promise<void> { | |
279 | + await this.getClient().query(`DROP TABLE IF EXISTS \`${table}\``); | |
280 | + } | |
281 | + | |
282 | + async alterTable(table: string, changes: TableAlterCommand[]): Promise<void> { | |
283 | + for (const change of changes) { | |
284 | + switch (change.type) { | |
285 | + case "add_column": | |
286 | + if (change.column) { | |
287 | + let def = `\`${change.column.name}\` ${this.mapColumnType(change.column.type)}`; | |
288 | + if (!change.column.nullable) def += " NOT NULL"; | |
289 | + if (change.column.defaultValue !== undefined) { | |
290 | + def += ` DEFAULT ${this.formatDefaultValue(change.column.defaultValue)}`; | |
291 | + } | |
292 | + await this.getClient().query(`ALTER TABLE \`${table}\` ADD COLUMN ${def}`); | |
293 | + } | |
294 | + break; | |
295 | + case "drop_column": | |
296 | + if (change.columnName) { | |
297 | + await this.getClient().query(`ALTER TABLE \`${table}\` DROP COLUMN \`${change.columnName}\``); | |
298 | + } | |
299 | + break; | |
300 | + case "add_index": | |
301 | + if (change.index) { | |
302 | + const uniqueStr = change.index.unique ? "UNIQUE " : ""; | |
303 | + const idxCols = change.index.columns.map((c) => `\`${c}\``).join(", "); | |
304 | + await this.getClient().query( | |
305 | + `CREATE ${uniqueStr}INDEX \`${change.index.name}\` ON \`${table}\` (${idxCols})` | |
306 | + ); | |
307 | + } | |
308 | + break; | |
309 | + case "drop_index": | |
310 | + if (change.indexName) { | |
311 | + await this.getClient().query(`DROP INDEX \`${change.indexName}\` ON \`${table}\``); | |
312 | + } | |
313 | + break; | |
314 | + } | |
315 | + } | |
316 | + } | |
317 | + | |
318 | + async runMigration(migration: Migration): Promise<void> { | |
319 | + await this.beginTransaction(); | |
320 | + try { | |
321 | + await migration.up(this); | |
322 | + await this.rawQuery( | |
323 | + "INSERT INTO `_migrations` (id, name, executed_at) VALUES (?, ?, ?)", | |
324 | + [migration.id, migration.name, new Date().toISOString()] | |
325 | + ); | |
326 | + await this.commitTransaction(); | |
327 | + } catch (error) { | |
328 | + await this.rollbackTransaction(); | |
329 | + throw error; | |
330 | + } | |
331 | + } | |
332 | + | |
333 | + async getMigrationStatus(): Promise<MigrationStatus[]> { | |
334 | + const exists = await this.tableExists("_migrations"); | |
335 | + if (!exists) { | |
336 | + await this.createTable("_migrations", { | |
337 | + columns: [ | |
338 | + { name: "id", type: "string", nullable: false }, | |
339 | + { name: "name", type: "string", nullable: false }, | |
340 | + { name: "executed_at", type: "datetime", nullable: true }, | |
341 | + ], | |
342 | + primaryKey: "id", | |
343 | + }); | |
344 | + } | |
345 | + | |
346 | + const result = await this.rawQuery<{ | |
347 | + id: string; | |
348 | + name: string; | |
349 | + executed_at: string | null; | |
350 | + }>("SELECT * FROM `_migrations` ORDER BY executed_at"); | |
351 | + | |
352 | + return result.rows.map((row) => ({ | |
353 | + id: row.id, | |
354 | + name: row.name, | |
355 | + executedAt: row.executed_at ? new Date(row.executed_at) : null, | |
356 | + status: row.executed_at ? "executed" : "pending", | |
357 | + })); | |
358 | + } | |
359 | + | |
360 | + protected mapColumnType(type: ColumnType): string { | |
361 | + const typeMap: Record<ColumnType, string> = { | |
362 | + string: "VARCHAR(255)", | |
363 | + text: "TEXT", | |
364 | + integer: "INT", | |
365 | + bigint: "BIGINT", | |
366 | + float: "FLOAT", | |
367 | + decimal: "DECIMAL(10,2)", | |
368 | + boolean: "BOOLEAN", | |
369 | + date: "DATE", | |
370 | + datetime: "DATETIME", | |
371 | + timestamp: "TIMESTAMP", | |
372 | + json: "JSON", | |
373 | + blob: "BLOB", | |
374 | + uuid: "CHAR(36)", | |
375 | + }; | |
376 | + return typeMap[type] || "TEXT"; | |
377 | + } | |
378 | + | |
379 | + private formatDefaultValue(value: unknown): string { | |
380 | + if (value === null) return "NULL"; | |
381 | + if (typeof value === "string") return `'${value}'`; | |
382 | + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; | |
383 | + return String(value); | |
384 | + } | |
385 | +} | |
0 | 386 | |