feat: add src/db/adapter/mongodb.ts
swadhinbiswas committed 8 months ago
bbd7a22
1 parent 91332f2
commit bbd7a22
1 file changed
+276 -0
Showing 1 changed file+276-0
▼src/db/adapter/mongodb.ts
+276-0
| @@ -0,0 +1,276 @@ | ||
1 | +import { | |
2 | + BaseDatabaseAdapter, | |
3 | + ColumnType, | |
4 | + DatabaseConfig, | |
5 | + Migration, | |
6 | + MigrationStatus, | |
7 | + QueryOptions, | |
8 | + QueryResult, | |
9 | + TableAlterCommand, | |
10 | + TableSchema, | |
11 | + WhereClause, | |
12 | +} from "./types"; | |
13 | + | |
14 | +const dynamicImport = new Function("m", "return import(m)") as (m: string) => Promise<any>; | |
15 | + | |
16 | +function toMongoFilter(where?: WhereClause[]): Record<string, unknown> { | |
17 | + if (!where?.length) return {}; | |
18 | + const filter: Record<string, unknown> = {}; | |
19 | + | |
20 | + for (const clause of where) { | |
21 | + const field = clause.field === "id" ? "_id" : clause.field; | |
22 | + switch (clause.operator) { | |
23 | + case "=": | |
24 | + case "eq": | |
25 | + filter[field] = clause.value; | |
26 | + break; | |
27 | + case "!=": | |
28 | + case "ne": | |
29 | + filter[field] = { $ne: clause.value }; | |
30 | + break; | |
31 | + case ">": | |
32 | + case "gt": | |
33 | + filter[field] = { $gt: clause.value }; | |
34 | + break; | |
35 | + case ">=": | |
36 | + case "gte": | |
37 | + filter[field] = { $gte: clause.value }; | |
38 | + break; | |
39 | + case "<": | |
40 | + case "lt": | |
41 | + filter[field] = { $lt: clause.value }; | |
42 | + break; | |
43 | + case "<=": | |
44 | + case "lte": | |
45 | + filter[field] = { $lte: clause.value }; | |
46 | + break; | |
47 | + case "in": | |
48 | + filter[field] = { $in: Array.isArray(clause.value) ? clause.value : [clause.value] }; | |
49 | + break; | |
50 | + case "not in": | |
51 | + filter[field] = { $nin: Array.isArray(clause.value) ? clause.value : [clause.value] }; | |
52 | + break; | |
53 | + case "like": | |
54 | + case "ilike": | |
55 | + filter[field] = { $regex: String(clause.value), $options: "i" }; | |
56 | + break; | |
57 | + case "is null": | |
58 | + case "isNull": | |
59 | + filter[field] = null; | |
60 | + break; | |
61 | + case "is not null": | |
62 | + case "isNotNull": | |
63 | + filter[field] = { $ne: null }; | |
64 | + break; | |
65 | + default: | |
66 | + break; | |
67 | + } | |
68 | + } | |
69 | + | |
70 | + return filter; | |
71 | +} | |
72 | + | |
73 | +function normalizeDoc<T>(doc: any): T { | |
74 | + if (!doc) return doc; | |
75 | + if (doc._id !== undefined) { | |
76 | + doc.id = typeof doc._id === "string" ? doc._id : String(doc._id); | |
77 | + } | |
78 | + return doc as T; | |
79 | +} | |
80 | + | |
81 | +export class MongoDBAdapter extends BaseDatabaseAdapter { | |
82 | + private client: any; | |
83 | + private db: any; | |
84 | + private session: any = null; | |
85 | + | |
86 | + async connect(): Promise<void> { | |
87 | + if (this.connected) return; | |
88 | + | |
89 | + const mongodb = await dynamicImport("mongodb"); | |
90 | + const MongoClient = mongodb.MongoClient; | |
91 | + | |
92 | + this.client = new MongoClient(this.config.url, { | |
93 | + ignoreUndefined: true, | |
94 | + }); | |
95 | + await this.client.connect(); | |
96 | + | |
97 | + if (this.config.database) { | |
98 | + this.db = this.client.db(this.config.database); | |
99 | + } else { | |
100 | + const parsed = new URL(this.config.url); | |
101 | + const dbName = parsed.pathname.replace(/^\//, "") || "opencodehub"; | |
102 | + this.db = this.client.db(dbName); | |
103 | + } | |
104 | + | |
105 | + this.connected = true; | |
106 | + } | |
107 | + | |
108 | + async disconnect(): Promise<void> { | |
109 | + if (!this.connected) return; | |
110 | + await this.client?.close(); | |
111 | + this.connected = false; | |
112 | + this.session = null; | |
113 | + } | |
114 | + | |
115 | + private collection(table: string) { | |
116 | + return this.db.collection(table); | |
117 | + } | |
118 | + | |
119 | + async findOne<T>(table: string, options?: QueryOptions): Promise<T | null> { | |
120 | + const filter = toMongoFilter(options?.where); | |
121 | + const doc = await this.collection(table).findOne(filter, this.session ? { session: this.session } : undefined); | |
122 | + return doc ? normalizeDoc<T>(doc) : null; | |
123 | + } | |
124 | + | |
125 | + async findMany<T>(table: string, options?: QueryOptions): Promise<T[]> { | |
126 | + const filter = toMongoFilter(options?.where); | |
127 | + let cursor = this.collection(table).find(filter, this.session ? { session: this.session } : undefined); | |
128 | + | |
129 | + if (options?.orderBy?.length) { | |
130 | + const sort: Record<string, 1 | -1> = {}; | |
131 | + for (const o of options.orderBy) { | |
132 | + sort[o.field === "id" ? "_id" : o.field] = o.direction === "asc" ? 1 : -1; | |
133 | + } | |
134 | + cursor = cursor.sort(sort); | |
135 | + } | |
136 | + if (options?.offset) cursor = cursor.skip(options.offset); | |
137 | + if (options?.limit) cursor = cursor.limit(options.limit); | |
138 | + | |
139 | + const docs = await cursor.toArray(); | |
140 | + return docs.map((d: any) => normalizeDoc<T>(d)); | |
141 | + } | |
142 | + | |
143 | + async create<T>(table: string, data: Partial<T>): Promise<T> { | |
144 | + const doc: Record<string, unknown> = { ...(data as Record<string, unknown>) }; | |
145 | + if (doc.id !== undefined) { | |
146 | + doc._id = String(doc.id); | |
147 | + delete doc.id; | |
148 | + } | |
149 | + | |
150 | + await this.collection(table).insertOne(doc, this.session ? { session: this.session } : undefined); | |
151 | + return normalizeDoc<T>(doc); | |
152 | + } | |
153 | + | |
154 | + async createMany<T>(table: string, data: Partial<T>[]): Promise<T[]> { | |
155 | + if (!data.length) return []; | |
156 | + const docs = data.map((item) => { | |
157 | + const doc: Record<string, unknown> = { ...(item as Record<string, unknown>) }; | |
158 | + if (doc.id !== undefined) { | |
159 | + doc._id = String(doc.id); | |
160 | + delete doc.id; | |
161 | + } | |
162 | + return doc; | |
163 | + }); | |
164 | + await this.collection(table).insertMany(docs, this.session ? { session: this.session } : undefined); | |
165 | + return docs.map((d) => normalizeDoc<T>(d)); | |
166 | + } | |
167 | + | |
168 | + async update<T>(table: string, id: string | number, data: Partial<T>): Promise<T | null> { | |
169 | + const patch = { ...(data as Record<string, unknown>) }; | |
170 | + delete patch.id; | |
171 | + await this.collection(table).updateOne( | |
172 | + { _id: String(id) }, | |
173 | + { $set: patch }, | |
174 | + this.session ? { session: this.session } : undefined | |
175 | + ); | |
176 | + const updated = await this.collection(table).findOne({ _id: String(id) }, this.session ? { session: this.session } : undefined); | |
177 | + return updated ? normalizeDoc<T>(updated) : null; | |
178 | + } | |
179 | + | |
180 | + async updateMany<T>(table: string, options: QueryOptions, data: Partial<T>): Promise<number> { | |
181 | + const patch = { ...(data as Record<string, unknown>) }; | |
182 | + delete patch.id; | |
183 | + const result = await this.collection(table).updateMany( | |
184 | + toMongoFilter(options.where), | |
185 | + { $set: patch }, | |
186 | + this.session ? { session: this.session } : undefined | |
187 | + ); | |
188 | + return result.modifiedCount || 0; | |
189 | + } | |
190 | + | |
191 | + async delete(table: string, id: string | number): Promise<boolean> { | |
192 | + const result = await this.collection(table).deleteOne({ _id: String(id) }, this.session ? { session: this.session } : undefined); | |
193 | + return (result.deletedCount || 0) > 0; | |
194 | + } | |
195 | + | |
196 | + async deleteMany(table: string, options?: QueryOptions): Promise<number> { | |
197 | + const result = await this.collection(table).deleteMany( | |
198 | + toMongoFilter(options?.where), | |
199 | + this.session ? { session: this.session } : undefined | |
200 | + ); | |
201 | + return result.deletedCount || 0; | |
202 | + } | |
203 | + | |
204 | + async count(table: string, options?: QueryOptions): Promise<number> { | |
205 | + return this.collection(table).countDocuments(toMongoFilter(options?.where), this.session ? { session: this.session } : undefined); | |
206 | + } | |
207 | + | |
208 | + async rawQuery<T>(query: string, params?: unknown[]): Promise<QueryResult<T>> { | |
209 | + throw new Error("MongoDB rawQuery is not supported via SQL string"); | |
210 | + } | |
211 | + | |
212 | + async beginTransaction(): Promise<void> { | |
213 | + if (this.session) return; | |
214 | + this.session = this.client.startSession(); | |
215 | + this.session.startTransaction(); | |
216 | + } | |
217 | + | |
218 | + async commitTransaction(): Promise<void> { | |
219 | + if (!this.session) return; | |
220 | + await this.session.commitTransaction(); | |
221 | + await this.session.endSession(); | |
222 | + this.session = null; | |
223 | + } | |
224 | + | |
225 | + async rollbackTransaction(): Promise<void> { | |
226 | + if (!this.session) return; | |
227 | + await this.session.abortTransaction(); | |
228 | + await this.session.endSession(); | |
229 | + this.session = null; | |
230 | + } | |
231 | + | |
232 | + async tableExists(table: string): Promise<boolean> { | |
233 | + const cols = await this.db.listCollections({ name: table }).toArray(); | |
234 | + return cols.length > 0; | |
235 | + } | |
236 | + | |
237 | + async createTable(table: string, schema: TableSchema): Promise<void> { | |
238 | + const exists = await this.tableExists(table); | |
239 | + if (!exists) await this.db.createCollection(table); | |
240 | + } | |
241 | + | |
242 | + async dropTable(table: string): Promise<void> { | |
243 | + const exists = await this.tableExists(table); | |
244 | + if (exists) await this.collection(table).drop(); | |
245 | + } | |
246 | + | |
247 | + async alterTable(table: string, changes: TableAlterCommand[]): Promise<void> { | |
248 | + // No-op by default for document model; schema evolves dynamically. | |
249 | + } | |
250 | + | |
251 | + async runMigration(migration: Migration): Promise<void> { | |
252 | + await migration.up(this); | |
253 | + const migrations = this.collection("_migrations"); | |
254 | + await migrations.updateOne( | |
255 | + { _id: migration.id }, | |
256 | + { $set: { name: migration.name, executedAt: new Date().toISOString() } }, | |
257 | + { upsert: true } | |
258 | + ); | |
259 | + } | |
260 | + | |
261 | + async getMigrationStatus(): Promise<MigrationStatus[]> { | |
262 | + const exists = await this.tableExists("_migrations"); | |
263 | + if (!exists) return []; | |
264 | + const rows = await this.collection("_migrations").find({}).toArray(); | |
265 | + return rows.map((row: any) => ({ | |
266 | + id: String(row._id), | |
267 | + name: row.name || String(row._id), | |
268 | + executedAt: row.executedAt ? new Date(row.executedAt) : null, | |
269 | + status: row.executedAt ? "executed" : "pending", | |
270 | + })); | |
271 | + } | |
272 | + | |
273 | + protected mapColumnType(type: ColumnType): string { | |
274 | + return type; | |
275 | + } | |
276 | +} | |
0 | 277 | |