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
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