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