stabilize-orm 1.1.2 → 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/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
- private getDBType(): DBType {
59
- return (this.client as any).config.type;
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
- ? `findOne:${this.table}:${id}:${options.relations.join(",")}`
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
- let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
130
- let insertedResult: T[] | undefined;
131
- let id: number | string | undefined;
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
- const dbType = this.getDBType();
195
+ let insertedResult: T[] | undefined;
196
+ let id: number | string | undefined;
197
+ const dbType = this.getDBType(client);
134
198
 
135
- if (dbType === DBType.Postgres) {
136
- query += " RETURNING *";
137
- insertedResult = await this.client.query<T>(query, params);
138
- id = (insertedResult?.[0] as any)?.id;
139
- } else {
140
- await this.client.query(query, params);
141
-
142
- if (dbType === DBType.SQLite) {
143
- id = (
144
- await this.client.query<{ id: number }>(
145
- "SELECT last_insert_rowid() as id",
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
- if (!id)
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
- const queryBuilder = this.find().where("id = ?", id);
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
- // If we are using write-through cache, we fetch the result now and cache it.
175
- if (this.cache) {
176
- await this.cache.invalidate(cacheKeys);
177
- if (this.cache.getStrategy() === "write-through") {
178
- // Fetch the full result for write-through cache, explicitly casting to T.
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
- const result = (insertedResult?.[0] ||
190
- (await queryBuilder.execute(this.client))[0]) as T;
191
- this.logger.logDebug(
192
- `Created ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
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
- await this.client.transaction(async () => {
216
- const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
217
- const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
218
- const placeholders = `(${keys.map(() => "?").join(", ")})`;
219
- let query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
220
- const params = batch.flatMap((entity) =>
221
- keys.map((k) => (entity as any)[k]),
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
- let batchResults: T[] = [];
225
- let ids: (number | string)[] = [];
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
- const cacheKeys = ids
246
- .map((id) => `findOne:${this.table}:${id}`)
247
- .concat(`find:${this.table}`);
248
-
249
- // If batchResults is empty (non-PostgreSQL), we need to fetch the full records.
250
- if (ids.length > 0 && batchResults.length === 0) {
251
- const queryBuilder = this.find().where(
252
- `id IN (${ids.map(() => "?").join(", ")})`,
253
- ...ids,
254
- );
255
- if (options.relations) {
256
- for (const rel of options.relations) {
257
- await this.loadRelation(queryBuilder, rel);
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
- if (this.cache) {
264
- await this.cache.invalidate(cacheKeys);
265
- if (this.cache.getStrategy() === "write-through") {
266
- await this.cache.set(
267
- `bulkCreate:${this.table}:${ids.join(",")}`,
268
- batchResults,
269
- 60,
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
- results.push(...batchResults);
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
- let result: T;
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
- if (this.cache) {
299
- await this.cache.invalidate(cacheKeys);
300
- if (this.cache.getStrategy() === "write-through") {
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
- // Fetch the result and explicitly cast to T. Logically, since we just updated the record, it must exist.
313
- result = (await this.findOne(id)) as T;
314
- this.logger.logDebug(
315
- `Updated ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
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
- await this.client.transaction(async () => {
336
- for (const update of batch) {
337
- const keys = Object.keys(update.set).filter((k) => this.columns[k]);
338
- const setClause = keys
339
- .map((k) => `${this.columns[k]?.name} = ?`)
340
- .join(", ");
341
- const query = `UPDATE ${this.table} SET ${setClause} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
342
- const params = [
343
- ...keys.map((k) => (update.set as any)[k]),
344
- ...update.where.params,
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
- return await this.client.transaction(async () => {
365
- const dbType = this.getDBType();
366
- const columns = Object.keys(entity).filter((k) => this.columns[k]);
367
- const columnNames = columns.map((k) => this.columns[k]?.name).join(", ");
368
- const placeholders = columns.map(() => "?").join(", ");
369
- const updateClause = columns
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
- .join(", ");
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
- let query: string;
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
- if (dbType === DBType.SQLite) {
377
- query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
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
- query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`;
380
- } else {
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
- const params = [
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
- if (!id)
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
- if (this.cache) {
420
- await this.cache.invalidatePattern(`find:${this.table}:*`);
421
- if (this.cache.getStrategy() === "write-through") {
422
- const queryBuilder = this.find().where("id = ?", id);
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
- const result = (await this.findOne(id)) as T;
433
- this.logger.logDebug(
434
- `Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
435
- );
436
- return result;
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
- if (this.softDeleteField) {
444
- await this.client.query(
445
- `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`,
446
- [new Date().toISOString(), id],
447
- );
448
- } else {
449
- await this.client.query(`DELETE FROM ${this.table} WHERE id = ?`, [id]);
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
- await this.client.transaction(async () => {
474
- const placeholders = batch.map(() => "?").join(", ");
475
- const query = this.softDeleteField
476
- ? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id IN (${placeholders})`
477
- : `DELETE FROM ${this.table} WHERE id IN (${placeholders})`;
478
- const params = this.softDeleteField
479
- ? [new Date().toISOString(), ...batch]
480
- : batch;
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
- return await this.client.transaction(async () => {
502
- await this.client.query(
503
- `UPDATE ${this.table} SET ${this.softDeleteField} = NULL WHERE id = ?`,
504
- [id],
505
- );
506
- if (this.cache) {
507
- await this.cache.invalidate([
508
- `find:${this.table}`,
509
- `findOne:${this.table}:${id}`,
510
- ]);
511
- }
512
- const result = (await this.findOne(id))!;
513
- this.logger.logDebug(
514
- `Recovered ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
515
- );
516
- return result;
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
+ }