feat: add src/db/adapter/redis.ts
swadhinbiswas committed 8 months ago
b4a0855
1 parent 7952467
commit b4a0855
1 file changed
+264 -0
Showing 1 changed file+264-0
▼src/db/adapter/redis.ts
+264-0
| @@ -0,0 +1,264 @@ | ||
1 | +import crypto from "node:crypto"; | |
2 | +import { | |
3 | + BaseDatabaseAdapter, | |
4 | + ColumnType, | |
5 | + DatabaseConfig, | |
6 | + Migration, | |
7 | + MigrationStatus, | |
8 | + QueryOptions, | |
9 | + QueryResult, | |
10 | + TableAlterCommand, | |
11 | + TableSchema, | |
12 | + WhereClause, | |
13 | +} from "./types"; | |
14 | + | |
15 | +function applyWhere<T extends Record<string, any>>(rows: T[], where?: WhereClause[]): T[] { | |
16 | + if (!where?.length) return rows; | |
17 | + return rows.filter((row) => | |
18 | + where.every((clause) => { | |
19 | + const value = row[clause.field]; | |
20 | + const clauseValue = clause.value as any; | |
21 | + switch (clause.operator) { | |
22 | + case "=": | |
23 | + case "eq": | |
24 | + return value === clauseValue; | |
25 | + case "!=": | |
26 | + case "ne": | |
27 | + return value !== clauseValue; | |
28 | + case ">": | |
29 | + case "gt": | |
30 | + return value > clauseValue; | |
31 | + case ">=": | |
32 | + case "gte": | |
33 | + return value >= clauseValue; | |
34 | + case "<": | |
35 | + case "lt": | |
36 | + return value < clauseValue; | |
37 | + case "<=": | |
38 | + case "lte": | |
39 | + return value <= clauseValue; | |
40 | + case "in": | |
41 | + return Array.isArray(clause.value) && clause.value.includes(value); | |
42 | + case "not in": | |
43 | + return Array.isArray(clause.value) && !clause.value.includes(value); | |
44 | + case "is null": | |
45 | + case "isNull": | |
46 | + return value == null; | |
47 | + case "is not null": | |
48 | + case "isNotNull": | |
49 | + return value != null; | |
50 | + case "like": | |
51 | + case "ilike": | |
52 | + return String(value ?? "").toLowerCase().includes(String(clause.value).toLowerCase()); | |
53 | + default: | |
54 | + return true; | |
55 | + } | |
56 | + }) | |
57 | + ); | |
58 | +} | |
59 | + | |
60 | +export class RedisAdapter extends BaseDatabaseAdapter { | |
61 | + private redis: any; | |
62 | + private keyPrefix = "db"; | |
63 | + | |
64 | + async connect(): Promise<void> { | |
65 | + if (this.connected) return; | |
66 | + const mod = await import("ioredis"); | |
67 | + const Redis = mod.default; | |
68 | + this.redis = new Redis(this.config.url); | |
69 | + await this.redis.ping(); | |
70 | + this.connected = true; | |
71 | + } | |
72 | + | |
73 | + async disconnect(): Promise<void> { | |
74 | + if (!this.connected) return; | |
75 | + await this.redis.quit(); | |
76 | + this.connected = false; | |
77 | + } | |
78 | + | |
79 | + private rowKey(table: string, id: string | number) { | |
80 | + return `${this.keyPrefix}:${table}:row:${id}`; | |
81 | + } | |
82 | + | |
83 | + private idsKey(table: string) { | |
84 | + return `${this.keyPrefix}:${table}:ids`; | |
85 | + } | |
86 | + | |
87 | + private tableMetaKey(table: string) { | |
88 | + return `${this.keyPrefix}:${table}:meta`; | |
89 | + } | |
90 | + | |
91 | + async findOne<T>(table: string, options?: QueryOptions): Promise<T | null> { | |
92 | + const rows = await this.findMany<T>(table, { ...options, limit: 1 }); | |
93 | + return rows[0] || null; | |
94 | + } | |
95 | + | |
96 | + async findMany<T>(table: string, options?: QueryOptions): Promise<T[]> { | |
97 | + const ids = await this.redis.smembers(this.idsKey(table)); | |
98 | + const rows: T[] = []; | |
99 | + | |
100 | + for (const id of ids) { | |
101 | + const raw = await this.redis.get(this.rowKey(table, id)); | |
102 | + if (!raw) continue; | |
103 | + rows.push(JSON.parse(raw)); | |
104 | + } | |
105 | + | |
106 | + let filtered = applyWhere(rows as any[], options?.where); | |
107 | + | |
108 | + if (options?.orderBy?.length) { | |
109 | + filtered.sort((a: any, b: any) => { | |
110 | + for (const rule of options.orderBy!) { | |
111 | + if (a[rule.field] === b[rule.field]) continue; | |
112 | + const cmp = a[rule.field] > b[rule.field] ? 1 : -1; | |
113 | + return rule.direction === "asc" ? cmp : -cmp; | |
114 | + } | |
115 | + return 0; | |
116 | + }); | |
117 | + } | |
118 | + | |
119 | + if (options?.offset) filtered = filtered.slice(options.offset); | |
120 | + if (options?.limit) filtered = filtered.slice(0, options.limit); | |
121 | + | |
122 | + return filtered as T[]; | |
123 | + } | |
124 | + | |
125 | + async create<T>(table: string, data: Partial<T>): Promise<T> { | |
126 | + const id = | |
127 | + (data as any).id || | |
128 | + (typeof crypto.randomUUID === "function" | |
129 | + ? crypto.randomUUID() | |
130 | + : `${Date.now()}_${Math.random().toString(36).slice(2)}`); | |
131 | + | |
132 | + const row = { ...(data as any), id }; | |
133 | + await this.redis.multi() | |
134 | + .set(this.rowKey(table, id), JSON.stringify(row)) | |
135 | + .sadd(this.idsKey(table), String(id)) | |
136 | + .exec(); | |
137 | + | |
138 | + return row as T; | |
139 | + } | |
140 | + | |
141 | + async createMany<T>(table: string, data: Partial<T>[]): Promise<T[]> { | |
142 | + const out: T[] = []; | |
143 | + for (const item of data) out.push(await this.create<T>(table, item)); | |
144 | + return out; | |
145 | + } | |
146 | + | |
147 | + async update<T>(table: string, id: string | number, data: Partial<T>): Promise<T | null> { | |
148 | + const key = this.rowKey(table, id); | |
149 | + const currentRaw = await this.redis.get(key); | |
150 | + if (!currentRaw) return null; | |
151 | + const current = JSON.parse(currentRaw); | |
152 | + const next = { ...current, ...(data as any), id: current.id ?? id }; | |
153 | + await this.redis.set(key, JSON.stringify(next)); | |
154 | + return next as T; | |
155 | + } | |
156 | + | |
157 | + async updateMany<T>(table: string, options: QueryOptions, data: Partial<T>): Promise<number> { | |
158 | + const rows = await this.findMany<any>(table, options); | |
159 | + for (const row of rows) { | |
160 | + await this.update(table, row.id, data); | |
161 | + } | |
162 | + return rows.length; | |
163 | + } | |
164 | + | |
165 | + async delete(table: string, id: string | number): Promise<boolean> { | |
166 | + const [deleted] = await this.redis.multi() | |
167 | + .del(this.rowKey(table, id)) | |
168 | + .srem(this.idsKey(table), String(id)) | |
169 | + .exec(); | |
170 | + return (deleted?.[1] || 0) > 0; | |
171 | + } | |
172 | + | |
173 | + async deleteMany(table: string, options?: QueryOptions): Promise<number> { | |
174 | + const rows = await this.findMany<any>(table, options); | |
175 | + for (const row of rows) { | |
176 | + await this.delete(table, row.id); | |
177 | + } | |
178 | + return rows.length; | |
179 | + } | |
180 | + | |
181 | + async count(table: string, options?: QueryOptions): Promise<number> { | |
182 | + if (!options?.where?.length) { | |
183 | + return this.redis.scard(this.idsKey(table)); | |
184 | + } | |
185 | + const rows = await this.findMany<any>(table, options); | |
186 | + return rows.length; | |
187 | + } | |
188 | + | |
189 | + async rawQuery<T>(query: string, params?: unknown[]): Promise<QueryResult<T>> { | |
190 | + throw new Error("Redis adapter does not support SQL rawQuery"); | |
191 | + } | |
192 | + | |
193 | + async beginTransaction(): Promise<void> { | |
194 | + // No-op for this adapter abstraction layer. | |
195 | + } | |
196 | + | |
197 | + async commitTransaction(): Promise<void> { | |
198 | + // No-op for this adapter abstraction layer. | |
199 | + } | |
200 | + | |
201 | + async rollbackTransaction(): Promise<void> { | |
202 | + // No-op for this adapter abstraction layer. | |
203 | + } | |
204 | + | |
205 | + async tableExists(table: string): Promise<boolean> { | |
206 | + const exists = await this.redis.exists(this.tableMetaKey(table)); | |
207 | + return exists === 1; | |
208 | + } | |
209 | + | |
210 | + async createTable(table: string, schema: TableSchema): Promise<void> { | |
211 | + await this.redis.set(this.tableMetaKey(table), JSON.stringify(schema)); | |
212 | + } | |
213 | + | |
214 | + async dropTable(table: string): Promise<void> { | |
215 | + const ids = await this.redis.smembers(this.idsKey(table)); | |
216 | + const multi = this.redis.multi(); | |
217 | + for (const id of ids) { | |
218 | + multi.del(this.rowKey(table, id)); | |
219 | + } | |
220 | + multi.del(this.idsKey(table)); | |
221 | + multi.del(this.tableMetaKey(table)); | |
222 | + await multi.exec(); | |
223 | + } | |
224 | + | |
225 | + async alterTable(table: string, changes: TableAlterCommand[]): Promise<void> { | |
226 | + const raw = await this.redis.get(this.tableMetaKey(table)); | |
227 | + const schema: TableSchema = raw ? JSON.parse(raw) : { columns: [] }; | |
228 | + for (const change of changes) { | |
229 | + if (change.type === "add_column" && change.column) { | |
230 | + schema.columns.push(change.column); | |
231 | + } | |
232 | + if (change.type === "drop_column" && change.columnName) { | |
233 | + schema.columns = schema.columns.filter((c) => c.name !== change.columnName); | |
234 | + } | |
235 | + } | |
236 | + await this.redis.set(this.tableMetaKey(table), JSON.stringify(schema)); | |
237 | + } | |
238 | + | |
239 | + async runMigration(migration: Migration): Promise<void> { | |
240 | + await migration.up(this); | |
241 | + await this.redis.hset(`${this.keyPrefix}:_migrations`, migration.id, JSON.stringify({ | |
242 | + id: migration.id, | |
243 | + name: migration.name, | |
244 | + executedAt: new Date().toISOString(), | |
245 | + })); | |
246 | + } | |
247 | + | |
248 | + async getMigrationStatus(): Promise<MigrationStatus[]> { | |
249 | + const raw = await this.redis.hgetall(`${this.keyPrefix}:_migrations`); | |
250 | + return Object.values(raw).map((v: any) => { | |
251 | + const row = JSON.parse(v); | |
252 | + return { | |
253 | + id: row.id, | |
254 | + name: row.name, | |
255 | + executedAt: row.executedAt ? new Date(row.executedAt) : null, | |
256 | + status: row.executedAt ? "executed" : "pending", | |
257 | + } as MigrationStatus; | |
258 | + }); | |
259 | + } | |
260 | + | |
261 | + protected mapColumnType(type: ColumnType): string { | |
262 | + return type; | |
263 | + } | |
264 | +} | |
0 | 265 | |