stabilize-orm 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +209 -155
- package/bun.lock +61 -0
- package/cache.ts +88 -17
- package/cli/stabilize-cli.ts +300 -432
- package/client.ts +153 -170
- package/decorators.ts +70 -4
- package/dist/cli/stabilize-cli.js +3093 -86
- package/dist/index.js +3023 -26
- package/index.ts +90 -51
- package/logger.ts +76 -75
- package/migrations.ts +157 -65
- package/package.json +5 -2
- package/query-builder.ts +103 -13
- package/repository.ts +447 -287
- package/types.ts +58 -29
package/repository.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file repository.ts
|
|
3
|
+
* @description Provides a data access layer for a specific model, handling all CRUD operations.
|
|
4
|
+
* @author ElectronSz
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
import { Cache } from "./cache";
|
|
2
8
|
import { DBClient } from "./client";
|
|
3
9
|
import { ConsoleLogger, type Logger } from "./logger";
|
|
4
10
|
import { QueryBuilder } from "./query-builder";
|
|
5
|
-
// DBConfig added to the type imports
|
|
6
11
|
import {
|
|
7
12
|
DBType,
|
|
8
13
|
RelationType,
|
|
9
14
|
StabilizeError,
|
|
10
15
|
type CacheConfig,
|
|
11
|
-
type DBConfig,
|
|
12
16
|
} from "./types";
|
|
13
17
|
import {
|
|
14
18
|
ModelKey,
|
|
@@ -18,8 +22,13 @@ import {
|
|
|
18
22
|
SoftDeleteKey,
|
|
19
23
|
} from "./decorators";
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Provides a generic repository for a model `T`.
|
|
27
|
+
* This class abstracts the database interactions for a specific model,
|
|
28
|
+
* offering methods for creating, reading, updating, and deleting records.
|
|
29
|
+
* @template T The model entity type.
|
|
30
|
+
*/
|
|
21
31
|
export class Repository<T> {
|
|
22
|
-
// Client remains DBClient type
|
|
23
32
|
private client: DBClient;
|
|
24
33
|
private cache: Cache | null;
|
|
25
34
|
private table: string;
|
|
@@ -38,6 +47,13 @@ export class Repository<T> {
|
|
|
38
47
|
private softDeleteField: string | null;
|
|
39
48
|
private logger: Logger;
|
|
40
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Creates an instance of Repository.
|
|
52
|
+
* @param client The database client instance for executing queries.
|
|
53
|
+
* @param model The model class constructor, decorated with `@Model`.
|
|
54
|
+
* @param cacheConfig Optional configuration for caching.
|
|
55
|
+
* @param logger A logger instance for logging messages.
|
|
56
|
+
*/
|
|
41
57
|
constructor(
|
|
42
58
|
client: DBClient,
|
|
43
59
|
model: new (...args: any[]) => T,
|
|
@@ -55,10 +71,22 @@ export class Repository<T> {
|
|
|
55
71
|
this.logger = logger;
|
|
56
72
|
}
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
|
|
74
|
+
/**
|
|
75
|
+
* @internal
|
|
76
|
+
* Gets the database type from the client's configuration.
|
|
77
|
+
* @param _client An optional client instance (used in transactions).
|
|
78
|
+
* @returns The `DBType` enum for the current database.
|
|
79
|
+
*/
|
|
80
|
+
private getDBType(_client?: DBClient): DBType {
|
|
81
|
+
const client = _client || this.client;
|
|
82
|
+
return client.config.type;
|
|
60
83
|
}
|
|
61
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @internal
|
|
87
|
+
* Validates an entity against the 'required' constraints defined in decorators.
|
|
88
|
+
* @param entity The partial entity to validate.
|
|
89
|
+
*/
|
|
62
90
|
private validate(entity: Partial<T>) {
|
|
63
91
|
for (const [key, rules] of Object.entries(this.validators)) {
|
|
64
92
|
const value = (entity as any)[key];
|
|
@@ -71,12 +99,18 @@ export class Repository<T> {
|
|
|
71
99
|
"VALIDATION_ERROR",
|
|
72
100
|
);
|
|
73
101
|
}
|
|
74
|
-
if (rules.includes("unique")) {
|
|
75
|
-
// Defer unique check to DB
|
|
76
|
-
}
|
|
77
102
|
}
|
|
78
103
|
}
|
|
79
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Creates a new `QueryBuilder` instance for the repository's table.
|
|
107
|
+
* Automatically adds a `WHERE` clause to exclude soft-deleted records if applicable.
|
|
108
|
+
* @returns A `QueryBuilder` instance for constructing a query.
|
|
109
|
+
* @example
|
|
110
|
+
* ```
|
|
111
|
+
* const activeUsersQuery = userRepository.find().where('status = ?', 'active');
|
|
112
|
+
* ```
|
|
113
|
+
*/
|
|
80
114
|
find(): QueryBuilder<T> {
|
|
81
115
|
const qb = new QueryBuilder<T>(this.table);
|
|
82
116
|
if (this.softDeleteField) {
|
|
@@ -85,10 +119,23 @@ export class Repository<T> {
|
|
|
85
119
|
return qb;
|
|
86
120
|
}
|
|
87
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Finds a single record by its primary key (id).
|
|
124
|
+
* @param id The ID of the record to find.
|
|
125
|
+
* @param options Optional: Specify relations to load.
|
|
126
|
+
* @param _client Optional: An internal client for transactions.
|
|
127
|
+
* @returns A promise that resolves to the entity or `null` if not found.
|
|
128
|
+
* @example
|
|
129
|
+
* ```
|
|
130
|
+
* const user = await userRepository.findOne(1);
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
88
133
|
async findOne(
|
|
89
134
|
id: number | string,
|
|
90
135
|
options: { relations?: string[] } = {},
|
|
136
|
+
_client?: DBClient,
|
|
91
137
|
): Promise<T | null> {
|
|
138
|
+
const client = _client || this.client;
|
|
92
139
|
const start = performance.now();
|
|
93
140
|
this.logger.logDebug(`Finding one ${this.table} with ID ${id}`);
|
|
94
141
|
const queryBuilder = this.find().where("id = ?", id).limit(1);
|
|
@@ -97,357 +144,410 @@ export class Repository<T> {
|
|
|
97
144
|
await this.loadRelation(queryBuilder, rel);
|
|
98
145
|
}
|
|
99
146
|
}
|
|
100
|
-
const cacheKey = options.relations
|
|
101
|
-
|
|
102
|
-
: `findOne:${this.table}:${id}`;
|
|
103
|
-
const results = await queryBuilder.execute(
|
|
104
|
-
this.client,
|
|
105
|
-
this.cache!,
|
|
106
|
-
cacheKey,
|
|
107
|
-
);
|
|
147
|
+
const cacheKey = `findOne:${this.table}:${id}:${options.relations?.join(",")}`;
|
|
148
|
+
const results = await queryBuilder.execute(client, this.cache!, cacheKey);
|
|
108
149
|
this.logger.logDebug(
|
|
109
150
|
`Found ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
110
151
|
);
|
|
111
152
|
return results[0] || null;
|
|
112
153
|
}
|
|
113
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Creates a new record in the database within a transaction.
|
|
157
|
+
* @param entity The data for the new record.
|
|
158
|
+
* @param options Optional: Specify relations to load on the returned entity.
|
|
159
|
+
* @returns A promise that resolves to the newly created entity.
|
|
160
|
+
* @example
|
|
161
|
+
* ```
|
|
162
|
+
* const newUser = await userRepository.create({ name: 'Ciniso Dlamini', email: 'lwazicd@icloud.com' });
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
114
165
|
async create(
|
|
115
166
|
entity: Partial<T>,
|
|
116
167
|
options: { relations?: string[] } = {},
|
|
168
|
+
): Promise<T> {
|
|
169
|
+
return this.client.transaction((txClient) =>
|
|
170
|
+
this._create(entity, options, txClient),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @internal
|
|
176
|
+
* The private implementation for creating a record, executed within a transaction.
|
|
177
|
+
*/
|
|
178
|
+
private async _create(
|
|
179
|
+
entity: Partial<T>,
|
|
180
|
+
options: { relations?: string[] },
|
|
181
|
+
client: DBClient,
|
|
117
182
|
): Promise<T> {
|
|
118
183
|
const start = performance.now();
|
|
119
184
|
this.logger.logDebug(
|
|
120
185
|
`Creating ${this.table} with data: ${JSON.stringify(entity)}`,
|
|
121
186
|
);
|
|
122
187
|
this.validate(entity);
|
|
123
|
-
return await this.client.transaction(async () => {
|
|
124
|
-
const keys = Object.keys(entity).filter((k) => this.columns[k]);
|
|
125
|
-
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
126
|
-
const placeholders = keys.map(() => "?").join(", ");
|
|
127
|
-
const params = keys.map((k) => (entity as any)[k]);
|
|
128
188
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
189
|
+
const keys = Object.keys(entity).filter((k) => this.columns[k]);
|
|
190
|
+
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
191
|
+
const placeholders = keys.map(() => "?").join(", ");
|
|
192
|
+
const params = keys.map((k) => (entity as any)[k]);
|
|
193
|
+
let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
|
|
132
194
|
|
|
133
|
-
|
|
195
|
+
let insertedResult: T[] | undefined;
|
|
196
|
+
let id: number | string | undefined;
|
|
197
|
+
const dbType = this.getDBType(client);
|
|
134
198
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
[],
|
|
147
|
-
)
|
|
148
|
-
)[0]?.id;
|
|
149
|
-
} else if (dbType === DBType.MySQL) {
|
|
150
|
-
id = (
|
|
151
|
-
await this.client.query<{ id: number }>(
|
|
152
|
-
"SELECT LAST_INSERT_ID() as id",
|
|
153
|
-
[],
|
|
154
|
-
)
|
|
155
|
-
)[0]?.id;
|
|
156
|
-
}
|
|
199
|
+
if (dbType === DBType.Postgres) {
|
|
200
|
+
query += " RETURNING *";
|
|
201
|
+
insertedResult = await client.query<T>(query, params);
|
|
202
|
+
id = (insertedResult?.[0] as any)?.id;
|
|
203
|
+
} else {
|
|
204
|
+
await client.query(query, params);
|
|
205
|
+
if (dbType === DBType.SQLite) {
|
|
206
|
+
id = (await client.query<{ id: number }>("SELECT last_insert_rowid() as id"))[0]?.id;
|
|
207
|
+
} else if (dbType === DBType.MySQL) {
|
|
208
|
+
const result = await client.query<{ "LAST_INSERT_ID()": number }>("SELECT LAST_INSERT_ID()");
|
|
209
|
+
id = result[0]?.["LAST_INSERT_ID()"];
|
|
157
210
|
}
|
|
211
|
+
}
|
|
158
212
|
|
|
159
|
-
|
|
160
|
-
throw new StabilizeError(
|
|
161
|
-
"Failed to retrieve inserted ID",
|
|
162
|
-
"INSERT_ERROR",
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
const cacheKeys = [`find:${this.table}`, `findOne:${this.table}:${id}`];
|
|
213
|
+
if (!id) throw new StabilizeError("Failed to retrieve inserted ID", "INSERT_ERROR");
|
|
166
214
|
|
|
167
|
-
|
|
168
|
-
if (options.relations) {
|
|
169
|
-
for (const rel of options.relations) {
|
|
170
|
-
await this.loadRelation(queryBuilder, rel);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
215
|
+
const result = insertedResult?.[0] ?? ((await this.findOne(id, options, client)) as T);
|
|
173
216
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const result = (insertedResult?.[0] ||
|
|
180
|
-
(await queryBuilder.execute(this.client))[0]) as T;
|
|
181
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
182
|
-
this.logger.logDebug(
|
|
183
|
-
`Created ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
184
|
-
);
|
|
185
|
-
return result;
|
|
186
|
-
}
|
|
217
|
+
if (this.cache) {
|
|
218
|
+
const cacheKeys = [`find:${this.table}`, `findOne:${this.table}:${id}`];
|
|
219
|
+
await this.cache.invalidate(cacheKeys);
|
|
220
|
+
if (this.cache.getStrategy() === "write-through") {
|
|
221
|
+
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
187
222
|
}
|
|
223
|
+
}
|
|
188
224
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
);
|
|
194
|
-
return result;
|
|
195
|
-
});
|
|
225
|
+
this.logger.logDebug(
|
|
226
|
+
`Created ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
227
|
+
);
|
|
228
|
+
return result;
|
|
196
229
|
}
|
|
197
|
-
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Creates multiple records in the database in batches.
|
|
233
|
+
* @param entities An array of entities to create.
|
|
234
|
+
* @param options Optional: Specify relations or batch size.
|
|
235
|
+
* @returns A promise that resolves to an array of the newly created entities.
|
|
236
|
+
* @example
|
|
237
|
+
* ```
|
|
238
|
+
* const newUsers = await userRepository.bulkCreate([
|
|
239
|
+
* { name: 'Ciniso' },
|
|
240
|
+
* { name: 'Lwazi' }
|
|
241
|
+
* ]);
|
|
242
|
+
* ```
|
|
243
|
+
*/
|
|
198
244
|
async bulkCreate(
|
|
199
245
|
entities: Partial<T>[],
|
|
200
246
|
options: { relations?: string[]; batchSize?: number } = {},
|
|
247
|
+
): Promise<T[]> {
|
|
248
|
+
return this.client.transaction((txClient) =>
|
|
249
|
+
this._bulkCreate(entities, options, txClient),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @internal
|
|
255
|
+
* The private implementation for bulk creating records, executed within a transaction.
|
|
256
|
+
*/
|
|
257
|
+
private async _bulkCreate(
|
|
258
|
+
entities: Partial<T>[],
|
|
259
|
+
options: { relations?: string[]; batchSize?: number },
|
|
260
|
+
client: DBClient,
|
|
201
261
|
): Promise<T[]> {
|
|
202
262
|
const start = performance.now();
|
|
203
263
|
this.logger.logDebug(
|
|
204
264
|
`Bulk creating ${entities.length} ${this.table} entities`,
|
|
205
265
|
);
|
|
206
266
|
if (!entities.length) return [];
|
|
267
|
+
|
|
207
268
|
const batchSize = options.batchSize || 1000;
|
|
208
269
|
entities.forEach((entity) => this.validate(entity));
|
|
209
270
|
|
|
210
|
-
const dbType = this.getDBType();
|
|
271
|
+
const dbType = this.getDBType(client);
|
|
211
272
|
const results: T[] = [];
|
|
212
273
|
|
|
213
274
|
for (let i = 0; i < entities.length; i += batchSize) {
|
|
214
275
|
const batch = entities.slice(i, i + batchSize);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
276
|
+
const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
|
|
277
|
+
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
278
|
+
const placeholders = `(${keys.map(() => "?").join(", ")})`;
|
|
279
|
+
let query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
|
|
280
|
+
const params = batch.flatMap((entity) =>
|
|
281
|
+
keys.map((k) => (entity as any)[k]),
|
|
282
|
+
);
|
|
223
283
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (dbType === DBType.Postgres) {
|
|
228
|
-
// PostgreSQL supports RETURNING * for multiple inserts
|
|
229
|
-
query += " RETURNING *";
|
|
230
|
-
batchResults = await this.client.query<T>(query, params);
|
|
231
|
-
ids = batchResults.map((r) => (r as any).id);
|
|
232
|
-
} else {
|
|
233
|
-
// SQLite/MySQL batch insert. ID retrieval remains complex.
|
|
234
|
-
await this.client.query(query, params);
|
|
235
|
-
|
|
236
|
-
// Unreliable ID retrieval kept for consistency, but should be reviewed for concurrency
|
|
237
|
-
ids = (
|
|
238
|
-
await this.client.query<{ id: number }>(
|
|
239
|
-
`SELECT id FROM ${this.table} ORDER BY id DESC LIMIT ?`,
|
|
240
|
-
[batch.length],
|
|
241
|
-
)
|
|
242
|
-
).map((row) => row.id);
|
|
243
|
-
}
|
|
284
|
+
let batchResults: T[] = [];
|
|
285
|
+
let ids: (number | string)[] = [];
|
|
244
286
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
batchResults = await queryBuilder.execute(this.client);
|
|
261
|
-
}
|
|
287
|
+
if (dbType === DBType.Postgres) {
|
|
288
|
+
query += " RETURNING *";
|
|
289
|
+
batchResults = await client.query<T>(query, params);
|
|
290
|
+
ids = batchResults.map((r) => (r as any).id);
|
|
291
|
+
} else {
|
|
292
|
+
await client.query(query, params);
|
|
293
|
+
ids = (
|
|
294
|
+
await client.query<{ id: number }>(
|
|
295
|
+
`SELECT id FROM ${this.table} ORDER BY id DESC LIMIT ?`,
|
|
296
|
+
[batch.length],
|
|
297
|
+
)
|
|
298
|
+
).map((row) => row.id);
|
|
299
|
+
}
|
|
262
300
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
);
|
|
301
|
+
if (ids.length > 0 && batchResults.length === 0) {
|
|
302
|
+
const queryBuilder = this.find().where(
|
|
303
|
+
`id IN (${ids.map(() => "?").join(", ")})`,
|
|
304
|
+
...ids,
|
|
305
|
+
);
|
|
306
|
+
if (options.relations) {
|
|
307
|
+
for (const rel of options.relations) {
|
|
308
|
+
await this.loadRelation(queryBuilder, rel);
|
|
271
309
|
}
|
|
272
310
|
}
|
|
273
|
-
|
|
274
|
-
}
|
|
311
|
+
batchResults = await queryBuilder.execute(client);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
results.push(...batchResults);
|
|
275
315
|
}
|
|
316
|
+
|
|
317
|
+
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
318
|
+
|
|
276
319
|
this.logger.logDebug(
|
|
277
320
|
`Bulk created ${results.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
278
321
|
);
|
|
279
322
|
return results;
|
|
280
323
|
}
|
|
281
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Updates a record by its ID within a transaction.
|
|
327
|
+
* @param id The ID of the record to update.
|
|
328
|
+
* @param entity An object containing the fields to update.
|
|
329
|
+
* @returns A promise that resolves to the updated entity.
|
|
330
|
+
* @example
|
|
331
|
+
* ```
|
|
332
|
+
* const updatedUser = await userRepository.update(1, { name: 'Ciniso Dlamini' });
|
|
333
|
+
* ```
|
|
334
|
+
*/
|
|
282
335
|
async update(id: number | string, entity: Partial<T>): Promise<T> {
|
|
336
|
+
return this.client.transaction((txClient) =>
|
|
337
|
+
this._update(id, entity, txClient),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* @internal
|
|
343
|
+
* The private implementation for updating a record, executed within a transaction.
|
|
344
|
+
*/
|
|
345
|
+
private async _update(
|
|
346
|
+
id: number | string,
|
|
347
|
+
entity: Partial<T>,
|
|
348
|
+
client: DBClient,
|
|
349
|
+
): Promise<T> {
|
|
283
350
|
const start = performance.now();
|
|
284
351
|
this.logger.logDebug(`Updating ${this.table} with ID ${id}`);
|
|
285
352
|
this.validate(entity);
|
|
286
|
-
return await this.client.transaction(async () => {
|
|
287
|
-
const keys = Object.keys(entity).filter((k) => this.columns[k]);
|
|
288
|
-
const setClause = keys
|
|
289
|
-
.map((k) => `${this.columns[k]?.name} = ?`)
|
|
290
|
-
.join(", ");
|
|
291
|
-
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
292
|
-
const params = [...keys.map((k) => (entity as any)[k]), id];
|
|
293
|
-
await this.client.query(query, params);
|
|
294
353
|
|
|
295
|
-
|
|
354
|
+
const keys = Object.keys(entity).filter((k) => this.columns[k]);
|
|
355
|
+
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
356
|
+
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
357
|
+
const params = [...keys.map((k) => (entity as any)[k]), id];
|
|
358
|
+
await client.query(query, params);
|
|
296
359
|
|
|
360
|
+
const result = await this.findOne(id, {}, client);
|
|
361
|
+
if (!result) throw new StabilizeError("Failed to find updated record.", "UPDATE_ERROR");
|
|
362
|
+
|
|
363
|
+
if (this.cache) {
|
|
297
364
|
const cacheKeys = [`find:${this.table}`, `findOne:${this.table}:${id}`];
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const queryBuilder = this.find().where("id = ?", id);
|
|
302
|
-
// Fetch the result and explicitly cast to T to satisfy the return type.
|
|
303
|
-
result = (await queryBuilder.execute(this.client))[0] as T;
|
|
304
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
305
|
-
this.logger.logDebug(
|
|
306
|
-
`Updated ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
307
|
-
);
|
|
308
|
-
return result;
|
|
309
|
-
}
|
|
365
|
+
await this.cache.invalidate(cacheKeys);
|
|
366
|
+
if (this.cache.getStrategy() === "write-through") {
|
|
367
|
+
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
310
368
|
}
|
|
369
|
+
}
|
|
311
370
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
return result;
|
|
318
|
-
});
|
|
371
|
+
this.logger.logDebug(
|
|
372
|
+
`Updated ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
373
|
+
);
|
|
374
|
+
return result;
|
|
319
375
|
}
|
|
320
376
|
|
|
377
|
+
/**
|
|
378
|
+
* Updates multiple records based on different conditions.
|
|
379
|
+
* @param updates An array of update operations, each with a `where` and `set` clause.
|
|
380
|
+
* @param options Optional: Specify batch size.
|
|
381
|
+
* @returns A promise that resolves when the operation is complete.
|
|
382
|
+
* @example
|
|
383
|
+
* ```
|
|
384
|
+
* await userRepository.bulkUpdate([
|
|
385
|
+
* { where: { condition: 'id = ?', params: [1] }, set: { status: 'inactive' } },
|
|
386
|
+
* { where: { condition: 'id = ?', params: [2] }, set: { status: 'inactive' } }
|
|
387
|
+
* ]);
|
|
388
|
+
* ```
|
|
389
|
+
*/
|
|
321
390
|
async bulkUpdate(
|
|
322
391
|
updates: { where: { condition: string; params: any[] }; set: Partial<T> }[],
|
|
323
392
|
options: { batchSize?: number } = {},
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
return this.client.transaction((txClient) =>
|
|
395
|
+
this._bulkUpdate(updates, options, txClient),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* @internal
|
|
401
|
+
* The private implementation for bulk updating records, executed within a transaction.
|
|
402
|
+
*/
|
|
403
|
+
private async _bulkUpdate(
|
|
404
|
+
updates: { where: { condition: string; params: any[] }; set: Partial<T> }[],
|
|
405
|
+
options: { batchSize?: number },
|
|
406
|
+
client: DBClient,
|
|
324
407
|
): Promise<void> {
|
|
325
408
|
const start = performance.now();
|
|
326
409
|
this.logger.logDebug(
|
|
327
410
|
`Bulk updating ${updates.length} ${this.table} entities`,
|
|
328
411
|
);
|
|
329
412
|
if (!updates.length) return;
|
|
413
|
+
|
|
330
414
|
const batchSize = options.batchSize || 1000;
|
|
331
415
|
updates.forEach((update) => this.validate(update.set));
|
|
332
416
|
|
|
333
417
|
for (let i = 0; i < updates.length; i += batchSize) {
|
|
334
418
|
const batch = updates.slice(i, i + batchSize);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
];
|
|
346
|
-
await this.client.query(query, params);
|
|
347
|
-
}
|
|
348
|
-
if (this.cache) {
|
|
349
|
-
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
350
|
-
}
|
|
351
|
-
});
|
|
419
|
+
for (const update of batch) {
|
|
420
|
+
const keys = Object.keys(update.set).filter((k) => this.columns[k]);
|
|
421
|
+
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
422
|
+
const query = `UPDATE ${this.table} SET ${setClause} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
423
|
+
const params = [
|
|
424
|
+
...keys.map((k) => (update.set as any)[k]),
|
|
425
|
+
...update.where.params,
|
|
426
|
+
];
|
|
427
|
+
await client.query(query, params);
|
|
428
|
+
}
|
|
352
429
|
}
|
|
430
|
+
|
|
431
|
+
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
432
|
+
|
|
353
433
|
this.logger.logDebug(
|
|
354
434
|
`Bulk updated ${updates.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
355
435
|
);
|
|
356
436
|
}
|
|
357
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Performs an "update or insert" operation based on a set of unique keys.
|
|
440
|
+
* @param entity The entity to upsert.
|
|
441
|
+
* @param keys The list of key names that uniquely identify a record.
|
|
442
|
+
* @returns A promise that resolves to the upserted entity.
|
|
443
|
+
* @example
|
|
444
|
+
* ```
|
|
445
|
+
* const user = await userRepository.upsert({ email: 'lwazicd@icloud.com', name: 'Lwazi' }, ['email']);
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
358
448
|
async upsert(entity: Partial<T>, keys: string[]): Promise<T> {
|
|
449
|
+
return this.client.transaction((txClient) =>
|
|
450
|
+
this._upsert(entity, keys, txClient),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @internal
|
|
456
|
+
* The private implementation for an upsert operation, executed within a transaction.
|
|
457
|
+
*/
|
|
458
|
+
private async _upsert(
|
|
459
|
+
entity: Partial<T>,
|
|
460
|
+
keys: string[],
|
|
461
|
+
client: DBClient,
|
|
462
|
+
): Promise<T> {
|
|
359
463
|
const start = performance.now();
|
|
360
464
|
this.logger.logDebug(
|
|
361
465
|
`Upserting ${this.table} with keys: ${keys.join(", ")}`,
|
|
362
466
|
);
|
|
363
467
|
this.validate(entity);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
468
|
+
|
|
469
|
+
const dbType = this.getDBType(client);
|
|
470
|
+
const columns = Object.keys(entity).filter((k) => this.columns[k]);
|
|
471
|
+
const columnNames = columns.map((k) => this.columns[k]?.name).join(", ");
|
|
472
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
473
|
+
const updateClause = columns
|
|
474
|
+
.filter((c) => !keys.includes(c))
|
|
475
|
+
.map((c) => `${this.columns[c]?.name} = ?`).join(", ");
|
|
476
|
+
|
|
477
|
+
let query: string;
|
|
478
|
+
const updateParams = columns.filter((c) => !keys.includes(c)).map((k) => (entity as any)[k]);
|
|
479
|
+
const insertParams = columns.map((k) => (entity as any)[k]);
|
|
480
|
+
let params = [...insertParams, ...updateParams];
|
|
481
|
+
|
|
482
|
+
if (dbType === DBType.SQLite) {
|
|
483
|
+
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
|
|
484
|
+
} else if (dbType === DBType.MySQL) {
|
|
485
|
+
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`;
|
|
486
|
+
} else { // PostgreSQL
|
|
487
|
+
const pgUpdateClause = columns
|
|
370
488
|
.filter((c) => !keys.includes(c))
|
|
371
|
-
.map((c) => `${this.columns[c]?.name} =
|
|
372
|
-
|
|
489
|
+
.map((c) => `${this.columns[c]?.name} = EXCLUDED.${this.columns[c]?.name}`).join(", ");
|
|
490
|
+
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT (${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${pgUpdateClause} RETURNING *`;
|
|
491
|
+
params = insertParams;
|
|
492
|
+
}
|
|
373
493
|
|
|
374
|
-
|
|
494
|
+
const results = await client.query<T>(query, params);
|
|
495
|
+
let id: number | string | undefined = (results[0] as any)?.id || (entity as any).id;
|
|
375
496
|
|
|
376
|
-
|
|
377
|
-
|
|
497
|
+
if (!id && dbType !== DBType.Postgres) {
|
|
498
|
+
if (dbType === DBType.SQLite) {
|
|
499
|
+
id = (await client.query<{ id: number }>("SELECT last_insert_rowid() as id"))[0]?.id;
|
|
378
500
|
} else if (dbType === DBType.MySQL) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
// PostgreSQL and default
|
|
382
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT (${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause} RETURNING *`;
|
|
501
|
+
const result = await client.query<{ "LAST_INSERT_ID()": number }>("SELECT LAST_INSERT_ID()");
|
|
502
|
+
id = result[0]?.["LAST_INSERT_ID()"];
|
|
383
503
|
}
|
|
504
|
+
}
|
|
384
505
|
|
|
385
|
-
|
|
386
|
-
...columns.map((k) => (entity as any)[k]),
|
|
387
|
-
...columns
|
|
388
|
-
.filter((c) => !keys.includes(c))
|
|
389
|
-
.map((k) => (entity as any)[k]),
|
|
390
|
-
];
|
|
391
|
-
const results = await this.client.query<T>(query, params);
|
|
392
|
-
|
|
393
|
-
let id: number | string | undefined =
|
|
394
|
-
(results[0] as any)?.id || (entity as any).id;
|
|
395
|
-
|
|
396
|
-
// Fallback ID retrieval for non-PostgreSQL if the driver didn't return it
|
|
397
|
-
if (!id && dbType === DBType.SQLite) {
|
|
398
|
-
id = (
|
|
399
|
-
await this.client.query<{ id: number }>(
|
|
400
|
-
"SELECT last_insert_rowid() as id",
|
|
401
|
-
[],
|
|
402
|
-
)
|
|
403
|
-
)[0]?.id;
|
|
404
|
-
} else if (!id && dbType === DBType.MySQL) {
|
|
405
|
-
id = (
|
|
406
|
-
await this.client.query<{ id: number }>(
|
|
407
|
-
"SELECT LAST_INSERT_ID() as id",
|
|
408
|
-
[],
|
|
409
|
-
)
|
|
410
|
-
)[0]?.id;
|
|
411
|
-
}
|
|
506
|
+
if (!id) throw new StabilizeError("Failed to retrieve upserted ID", "UPSERT_ERROR");
|
|
412
507
|
|
|
413
|
-
|
|
414
|
-
throw new StabilizeError(
|
|
415
|
-
"Failed to retrieve upserted ID",
|
|
416
|
-
"UPSERT_ERROR",
|
|
417
|
-
);
|
|
508
|
+
const result = results[0] ?? ((await this.findOne(id, {}, client)) as T);
|
|
418
509
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const result = (await queryBuilder.execute(this.client))[0] as T;
|
|
424
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
425
|
-
this.logger.logDebug(
|
|
426
|
-
`Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
427
|
-
);
|
|
428
|
-
return result;
|
|
429
|
-
}
|
|
510
|
+
if (this.cache) {
|
|
511
|
+
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
512
|
+
if (this.cache.getStrategy() === "write-through") {
|
|
513
|
+
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
430
514
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.logger.logDebug(
|
|
518
|
+
`Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
519
|
+
);
|
|
520
|
+
return result;
|
|
438
521
|
}
|
|
439
522
|
|
|
523
|
+
/**
|
|
524
|
+
* Deletes a record by its ID. Performs a soft delete if enabled on the model.
|
|
525
|
+
* @param id The ID of the record to delete.
|
|
526
|
+
* @returns A promise that resolves when the operation is complete.
|
|
527
|
+
* @example
|
|
528
|
+
* ```
|
|
529
|
+
* await userRepository.delete(1);
|
|
530
|
+
* ```
|
|
531
|
+
*/
|
|
440
532
|
async delete(id: number | string): Promise<void> {
|
|
533
|
+
return this.client.transaction((txClient) => this._delete(id, txClient));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* @internal
|
|
538
|
+
* The private implementation for deleting a record, executed within a transaction.
|
|
539
|
+
*/
|
|
540
|
+
private async _delete(id: number | string, client: DBClient): Promise<void> {
|
|
441
541
|
const start = performance.now();
|
|
442
542
|
this.logger.logDebug(`Deleting ${this.table} with ID ${id}`);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
543
|
+
|
|
544
|
+
const query = this.softDeleteField
|
|
545
|
+
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
546
|
+
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
547
|
+
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
548
|
+
|
|
549
|
+
await client.query(query, params);
|
|
550
|
+
|
|
451
551
|
if (this.cache) {
|
|
452
552
|
await this.cache.invalidate([
|
|
453
553
|
`find:${this.table}`,
|
|
@@ -459,37 +559,77 @@ export class Repository<T> {
|
|
|
459
559
|
);
|
|
460
560
|
}
|
|
461
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Deletes multiple records by their IDs in batches.
|
|
564
|
+
* @param ids An array of IDs to delete.
|
|
565
|
+
* @param options Optional: Specify batch size.
|
|
566
|
+
* @returns A promise that resolves when the operation is complete.
|
|
567
|
+
* @example
|
|
568
|
+
* ```
|
|
569
|
+
* await userRepository.bulkDelete([1, 2, 3]);
|
|
570
|
+
* ```
|
|
571
|
+
*/
|
|
462
572
|
async bulkDelete(
|
|
463
573
|
ids: (number | string)[],
|
|
464
574
|
options: { batchSize?: number } = {},
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
return this.client.transaction((txClient) =>
|
|
577
|
+
this._bulkDelete(ids, options, txClient),
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* @internal
|
|
583
|
+
* The private implementation for bulk deleting records, executed within a transaction.
|
|
584
|
+
*/
|
|
585
|
+
private async _bulkDelete(
|
|
586
|
+
ids: (number | string)[],
|
|
587
|
+
options: { batchSize?: number },
|
|
588
|
+
client: DBClient,
|
|
465
589
|
): Promise<void> {
|
|
466
590
|
const start = performance.now();
|
|
467
591
|
this.logger.logDebug(`Bulk deleting ${ids.length} ${this.table} entities`);
|
|
468
592
|
if (!ids.length) return;
|
|
469
|
-
const batchSize = options.batchSize || 1000;
|
|
470
593
|
|
|
594
|
+
const batchSize = options.batchSize || 1000;
|
|
471
595
|
for (let i = 0; i < ids.length; i += batchSize) {
|
|
472
596
|
const batch = ids.slice(i, i + batchSize);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
await this.client.query(query, params);
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
if (this.cache) {
|
|
485
|
-
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
597
|
+
const placeholders = batch.map(() => "?").join(", ");
|
|
598
|
+
const query = this.softDeleteField
|
|
599
|
+
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id IN (${placeholders})`
|
|
600
|
+
: `DELETE FROM ${this.table} WHERE id IN (${placeholders})`;
|
|
601
|
+
const params = this.softDeleteField
|
|
602
|
+
? [new Date().toISOString(), ...batch]
|
|
603
|
+
: batch;
|
|
604
|
+
await client.query(query, params);
|
|
486
605
|
}
|
|
606
|
+
|
|
607
|
+
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
608
|
+
|
|
487
609
|
this.logger.logDebug(
|
|
488
610
|
`Bulk deleted ${ids.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
489
611
|
);
|
|
490
612
|
}
|
|
491
613
|
|
|
614
|
+
/**
|
|
615
|
+
* Recovers a soft-deleted record by its ID.
|
|
616
|
+
* Throws an error if soft delete is not enabled on the model.
|
|
617
|
+
* @param id The ID of the record to recover.
|
|
618
|
+
* @returns A promise that resolves to the recovered entity.
|
|
619
|
+
* @example
|
|
620
|
+
* ```
|
|
621
|
+
* const recoveredUser = await userRepository.recover(1);
|
|
622
|
+
* ```
|
|
623
|
+
*/
|
|
492
624
|
async recover(id: number | string): Promise<T> {
|
|
625
|
+
return this.client.transaction((txClient) => this._recover(id, txClient));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* @internal
|
|
630
|
+
* The private implementation for recovering a record, executed within a transaction.
|
|
631
|
+
*/
|
|
632
|
+
private async _recover(id: number | string, client: DBClient): Promise<T> {
|
|
493
633
|
const start = performance.now();
|
|
494
634
|
this.logger.logDebug(`Recovering ${this.table} with ID ${id}`);
|
|
495
635
|
if (!this.softDeleteField) {
|
|
@@ -498,25 +638,39 @@ export class Repository<T> {
|
|
|
498
638
|
"RECOVER_ERROR",
|
|
499
639
|
);
|
|
500
640
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
641
|
+
|
|
642
|
+
await client.query(
|
|
643
|
+
`UPDATE ${this.table} SET ${this.softDeleteField} = NULL WHERE id = ?`,
|
|
644
|
+
[id],
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
const result = await this.findOne(id, {}, client);
|
|
648
|
+
if (!result) throw new StabilizeError("Failed to find recovered record.", "RECOVER_ERROR");
|
|
649
|
+
|
|
650
|
+
if (this.cache) {
|
|
651
|
+
await this.cache.invalidate([
|
|
652
|
+
`find:${this.table}`,
|
|
653
|
+
`findOne:${this.table}:${id}`,
|
|
654
|
+
]);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
this.logger.logDebug(
|
|
658
|
+
`Recovered ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
659
|
+
);
|
|
660
|
+
return result;
|
|
518
661
|
}
|
|
519
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Executes a raw SQL query directly against the database.
|
|
665
|
+
* Bypasses most ORM abstractions. Use with caution.
|
|
666
|
+
* @param query The raw SQL query string with `?` placeholders.
|
|
667
|
+
* @param params An array of parameters to bind to the query.
|
|
668
|
+
* @returns A promise that resolves to an array of results.
|
|
669
|
+
* @example
|
|
670
|
+
* ```
|
|
671
|
+
* const activeUsers = await userRepository.rawQuery('SELECT * FROM users WHERE status = ?', ['active']);
|
|
672
|
+
* ```
|
|
673
|
+
*/
|
|
520
674
|
async rawQuery<T>(query: string, params: any[] = []): Promise<T[]> {
|
|
521
675
|
const start = performance.now();
|
|
522
676
|
this.logger.logDebug(`Executing raw query: ${query}`);
|
|
@@ -527,6 +681,12 @@ export class Repository<T> {
|
|
|
527
681
|
return result;
|
|
528
682
|
}
|
|
529
683
|
|
|
684
|
+
/**
|
|
685
|
+
* @internal
|
|
686
|
+
* Loads a relation by adding the appropriate JOIN clause to a query builder.
|
|
687
|
+
* @param queryBuilder The `QueryBuilder` instance to modify.
|
|
688
|
+
* @param relation The name of the relation to load.
|
|
689
|
+
*/
|
|
530
690
|
private async loadRelation(queryBuilder: QueryBuilder<T>, relation: string) {
|
|
531
691
|
this.logger.logDebug(`Loading relation ${relation} for ${this.table}`);
|
|
532
692
|
const rel = this.relations[relation];
|
|
@@ -562,4 +722,4 @@ export class Repository<T> {
|
|
|
562
722
|
);
|
|
563
723
|
}
|
|
564
724
|
}
|
|
565
|
-
}
|
|
725
|
+
}
|