stabilize-orm 1.1.7 → 1.2.0
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 +150 -76
- package/client.ts +110 -65
- package/decorators.ts +19 -14
- package/hooks.ts +33 -0
- package/index.ts +5 -2
- package/migrations.ts +147 -70
- package/package.json +1 -1
- package/repository.ts +378 -60
package/repository.ts
CHANGED
|
@@ -20,7 +20,12 @@ import {
|
|
|
20
20
|
ValidatorKey,
|
|
21
21
|
RelationKey,
|
|
22
22
|
SoftDeleteKey,
|
|
23
|
+
VersionedKey,
|
|
23
24
|
} from "./decorators";
|
|
25
|
+
import { getHooks, type HookType } from "./hooks";
|
|
26
|
+
|
|
27
|
+
type VersionOperation = "insert" | "update" | "delete";
|
|
28
|
+
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Provides a generic repository for a model `T`.
|
|
@@ -46,6 +51,8 @@ export class Repository<T> {
|
|
|
46
51
|
>;
|
|
47
52
|
private softDeleteField: string | null;
|
|
48
53
|
private logger: Logger;
|
|
54
|
+
private versioned: boolean;
|
|
55
|
+
private historyTable: string;
|
|
49
56
|
|
|
50
57
|
/**
|
|
51
58
|
* Creates an instance of Repository.
|
|
@@ -69,6 +76,8 @@ export class Repository<T> {
|
|
|
69
76
|
this.softDeleteField =
|
|
70
77
|
Reflect.getMetadata(SoftDeleteKey, model.prototype) || null;
|
|
71
78
|
this.logger = logger;
|
|
79
|
+
this.versioned = !!Reflect.getMetadata(VersionedKey, model);
|
|
80
|
+
this.historyTable = `${this.table}_history`;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/**
|
|
@@ -102,6 +111,17 @@ export class Repository<T> {
|
|
|
102
111
|
}
|
|
103
112
|
}
|
|
104
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Runs lifecycle hooks of a given type for the entity.
|
|
116
|
+
* @param entity The entity instance.
|
|
117
|
+
* @param type The hook type (e.g., 'beforeCreate').
|
|
118
|
+
*/
|
|
119
|
+
private async runHooks(entity: any, type: HookType): Promise<void> {
|
|
120
|
+
for (const hook of getHooks(entity, type)) {
|
|
121
|
+
await hook();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
/**
|
|
106
126
|
* Creates a new `QueryBuilder` instance for the repository's table.
|
|
107
127
|
* Automatically adds a `WHERE` clause to exclude soft-deleted records if applicable.
|
|
@@ -152,6 +172,136 @@ export class Repository<T> {
|
|
|
152
172
|
return results[0] || null;
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Snapshot query: get record as it was at a point in time.
|
|
177
|
+
*/
|
|
178
|
+
async asOf(
|
|
179
|
+
id: number | string,
|
|
180
|
+
asOfDate: Date,
|
|
181
|
+
_client?: DBClient
|
|
182
|
+
): Promise<T | null> {
|
|
183
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
184
|
+
const client = _client || this.client;
|
|
185
|
+
const rows = await client.query<T>(
|
|
186
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY version DESC LIMIT 1`,
|
|
187
|
+
[id, asOfDate, asOfDate]
|
|
188
|
+
);
|
|
189
|
+
return rows[0] || null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get all history for a record.
|
|
194
|
+
*/
|
|
195
|
+
async history(
|
|
196
|
+
id: number | string,
|
|
197
|
+
_client?: DBClient
|
|
198
|
+
): Promise<T[]> {
|
|
199
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
200
|
+
const client = _client || this.client;
|
|
201
|
+
return client.query<T>(
|
|
202
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? ORDER BY version ASC`,
|
|
203
|
+
[id]
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Rollback a record to a previous version.
|
|
211
|
+
*/
|
|
212
|
+
async rollback(
|
|
213
|
+
id: number | string,
|
|
214
|
+
version: number,
|
|
215
|
+
_client?: DBClient
|
|
216
|
+
): Promise<T> {
|
|
217
|
+
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
218
|
+
const client = _client || this.client;
|
|
219
|
+
return client.transaction(async (txClient) => {
|
|
220
|
+
const rows = await txClient.query<T>(
|
|
221
|
+
`SELECT * FROM ${this.historyTable} WHERE id = ? AND version = ? LIMIT 1`,
|
|
222
|
+
[id, version]
|
|
223
|
+
);
|
|
224
|
+
if (!rows.length) throw new StabilizeError("Version not found", "ROLLBACK_ERROR");
|
|
225
|
+
|
|
226
|
+
const entity = rows[0];
|
|
227
|
+
const columns = Object.keys(this.columns).filter((c) => c !== "id");
|
|
228
|
+
const setClause = columns.map((c) => `${this.columns[c]!.name} = ?`).join(", ");
|
|
229
|
+
const params = columns.map((c) => (entity as any)[c]);
|
|
230
|
+
|
|
231
|
+
await txClient.query(
|
|
232
|
+
`UPDATE ${this.table} SET ${setClause} WHERE id = ?`,
|
|
233
|
+
[...params, id]
|
|
234
|
+
);
|
|
235
|
+
await this.writeHistory({ ...entity, version: version + 1 }, "update", txClient);
|
|
236
|
+
return this.findOne(id, {}, txClient) as Promise<T>;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Writes a versioned history row for the entity to the history table.
|
|
243
|
+
*
|
|
244
|
+
* This method maps entity property keys to their corresponding SQL column names
|
|
245
|
+
* (as defined in metadata) to ensure that inserts match the schema for all supported
|
|
246
|
+
* databases (Postgres, MySQL, SQLite).
|
|
247
|
+
*
|
|
248
|
+
* This function works for Postgres, MySQL, and SQLite, and uses positional parameters
|
|
249
|
+
* (properly formatted for the target database) for safety and compatibility.
|
|
250
|
+
*
|
|
251
|
+
* @param entity - The entity object being versioned
|
|
252
|
+
* @param operation - The operation performed ("insert", "update", "delete")
|
|
253
|
+
* @param client - The database client to use for the insert
|
|
254
|
+
* @param user - The user/system responsible for the change (default: "system")
|
|
255
|
+
*/
|
|
256
|
+
private async writeHistory(
|
|
257
|
+
entity: any,
|
|
258
|
+
operation: VersionOperation,
|
|
259
|
+
client: DBClient,
|
|
260
|
+
user?: string
|
|
261
|
+
) {
|
|
262
|
+
if (!this.versioned) return;
|
|
263
|
+
|
|
264
|
+
// Get property keys and corresponding SQL column names
|
|
265
|
+
const propertyKeys = Object.keys(this.columns);
|
|
266
|
+
const sqlColumnNames = propertyKeys.map((k) => this.columns[k]!.name);
|
|
267
|
+
|
|
268
|
+
// Build historyColumns using SQL column names
|
|
269
|
+
const historyColumns = [
|
|
270
|
+
...sqlColumnNames,
|
|
271
|
+
"operation",
|
|
272
|
+
"version",
|
|
273
|
+
"valid_from",
|
|
274
|
+
"valid_to",
|
|
275
|
+
"modified_by",
|
|
276
|
+
"modified_at"
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
// Map values from entity using property keys
|
|
280
|
+
const values = propertyKeys.map((k) => entity[k]);
|
|
281
|
+
|
|
282
|
+
const params = [
|
|
283
|
+
...values,
|
|
284
|
+
operation,
|
|
285
|
+
entity.version || 1,
|
|
286
|
+
new Date(),
|
|
287
|
+
null,
|
|
288
|
+
user || "system",
|
|
289
|
+
new Date()
|
|
290
|
+
];
|
|
291
|
+
// Database-agnostic placeholder formatting
|
|
292
|
+
let placeholders: string;
|
|
293
|
+
if (client.config.type === DBType.Postgres) {
|
|
294
|
+
placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
|
|
295
|
+
} else {
|
|
296
|
+
placeholders = params.map(() => "?").join(", ");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
await client.query(
|
|
300
|
+
`INSERT INTO ${this.historyTable} (${historyColumns.join(", ")}) VALUES (${placeholders})`,
|
|
301
|
+
params
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
155
305
|
/**
|
|
156
306
|
* Creates a new record in the database within a transaction.
|
|
157
307
|
* @param entity The data for the new record.
|
|
@@ -166,11 +316,22 @@ export class Repository<T> {
|
|
|
166
316
|
entity: Partial<T>,
|
|
167
317
|
options: { relations?: string[] } = {},
|
|
168
318
|
): Promise<T> {
|
|
169
|
-
return this.client.transaction((txClient) =>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
319
|
+
return this.client.transaction(async (txClient) => {
|
|
320
|
+
const instance = new (Object.getPrototypeOf(entity).constructor || Object)();
|
|
321
|
+
Object.assign(instance, entity);
|
|
322
|
+
|
|
323
|
+
await this.runHooks(instance, "beforeCreate");
|
|
324
|
+
await this.runHooks(instance, "beforeSave");
|
|
325
|
+
|
|
326
|
+
const result = await this._create(entity, options, txClient);
|
|
173
327
|
|
|
328
|
+
await this.runHooks(result, "afterCreate");
|
|
329
|
+
await this.runHooks(result, "afterSave");
|
|
330
|
+
|
|
331
|
+
await this.writeHistory(result, "insert", txClient);
|
|
332
|
+
return result;
|
|
333
|
+
});
|
|
334
|
+
}
|
|
174
335
|
/**
|
|
175
336
|
* @internal
|
|
176
337
|
* The private implementation for creating a record, executed within a transaction.
|
|
@@ -227,7 +388,7 @@ export class Repository<T> {
|
|
|
227
388
|
);
|
|
228
389
|
return result;
|
|
229
390
|
}
|
|
230
|
-
|
|
391
|
+
|
|
231
392
|
/**
|
|
232
393
|
* Creates multiple records in the database in batches.
|
|
233
394
|
* @param entities An array of entities to create.
|
|
@@ -245,9 +406,30 @@ export class Repository<T> {
|
|
|
245
406
|
entities: Partial<T>[],
|
|
246
407
|
options: { relations?: string[]; batchSize?: number } = {},
|
|
247
408
|
): Promise<T[]> {
|
|
248
|
-
return this.client.transaction((txClient) =>
|
|
249
|
-
|
|
250
|
-
|
|
409
|
+
return this.client.transaction(async (txClient) => {
|
|
410
|
+
// Prepare entity instances for hooks
|
|
411
|
+
const preparedEntities = entities.map(data => {
|
|
412
|
+
const instance = new (this as any).model();
|
|
413
|
+
Object.assign(instance, data);
|
|
414
|
+
return instance;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
for (const entity of preparedEntities) {
|
|
418
|
+
await this.runHooks(entity, "beforeCreate");
|
|
419
|
+
await this.runHooks(entity, "beforeSave");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const results = await this._bulkCreate(entities, options, txClient);
|
|
423
|
+
|
|
424
|
+
for (const result of results) {
|
|
425
|
+
await this.runHooks(result, "afterCreate");
|
|
426
|
+
await this.runHooks(result, "afterSave");
|
|
427
|
+
if (this.versioned) {
|
|
428
|
+
await this.writeHistory(result, "insert", txClient);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return results;
|
|
432
|
+
});
|
|
251
433
|
}
|
|
252
434
|
|
|
253
435
|
/**
|
|
@@ -275,47 +457,72 @@ export class Repository<T> {
|
|
|
275
457
|
const batch = entities.slice(i, i + batchSize);
|
|
276
458
|
const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
|
|
277
459
|
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
278
|
-
|
|
279
|
-
let query
|
|
280
|
-
|
|
460
|
+
|
|
461
|
+
let query: string;
|
|
462
|
+
let params: any[] = batch.flatMap((entity) =>
|
|
281
463
|
keys.map((k) => (entity as any)[k]),
|
|
282
464
|
);
|
|
283
465
|
|
|
284
|
-
let batchResults: T[] = [];
|
|
285
|
-
let ids: (number | string)[] = [];
|
|
286
|
-
|
|
287
466
|
if (dbType === DBType.Postgres) {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
467
|
+
// PostgreSQL: numbered placeholders ($1, $2, ...)
|
|
468
|
+
let paramIdx = 1;
|
|
469
|
+
const valuePlaceholders = batch
|
|
470
|
+
.map(
|
|
471
|
+
() =>
|
|
472
|
+
`(${keys.map(() => `$${paramIdx++}`).join(", ")})`
|
|
473
|
+
)
|
|
474
|
+
.join(", ");
|
|
475
|
+
query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${valuePlaceholders} RETURNING *`;
|
|
476
|
+
const batchResults = await client.query<T>(query, params);
|
|
477
|
+
const ids = batchResults.map((r) => (r as any).id);
|
|
478
|
+
|
|
479
|
+
// Handle relation loading if needed
|
|
480
|
+
let finalResults = batchResults;
|
|
481
|
+
if (ids.length > 0 && batchResults.length === 0) {
|
|
482
|
+
const queryBuilder = this.find().where(
|
|
483
|
+
`id IN (${ids.map(() => "?").join(", ")})`,
|
|
484
|
+
...ids,
|
|
485
|
+
);
|
|
486
|
+
if (options.relations) {
|
|
487
|
+
for (const rel of options.relations) {
|
|
488
|
+
await this.loadRelation(queryBuilder, rel);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
finalResults = await queryBuilder.execute(client);
|
|
492
|
+
}
|
|
493
|
+
results.push(...finalResults);
|
|
494
|
+
|
|
291
495
|
} else {
|
|
496
|
+
// SQLite/MySQL: ? placeholders
|
|
497
|
+
const placeholders = `(${keys.map(() => "?").join(", ")})`;
|
|
498
|
+
query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
|
|
292
499
|
await client.query(query, params);
|
|
293
|
-
ids = (
|
|
500
|
+
const ids = (
|
|
294
501
|
await client.query<{ id: number }>(
|
|
295
502
|
`SELECT id FROM ${this.table} ORDER BY id DESC LIMIT ?`,
|
|
296
503
|
[batch.length],
|
|
297
504
|
)
|
|
298
505
|
).map((row) => row.id);
|
|
299
|
-
}
|
|
300
506
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
507
|
+
let batchResults: T[] = [];
|
|
508
|
+
if (ids.length > 0) {
|
|
509
|
+
const queryBuilder = this.find().where(
|
|
510
|
+
`id IN (${ids.map(() => "?").join(", ")})`,
|
|
511
|
+
...ids,
|
|
512
|
+
);
|
|
513
|
+
if (options.relations) {
|
|
514
|
+
for (const rel of options.relations) {
|
|
515
|
+
await this.loadRelation(queryBuilder, rel);
|
|
516
|
+
}
|
|
309
517
|
}
|
|
518
|
+
batchResults = await queryBuilder.execute(client);
|
|
310
519
|
}
|
|
311
|
-
|
|
520
|
+
results.push(...batchResults);
|
|
312
521
|
}
|
|
313
|
-
|
|
314
|
-
results.push(...batchResults);
|
|
315
522
|
}
|
|
316
|
-
|
|
523
|
+
|
|
317
524
|
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
318
|
-
|
|
525
|
+
|
|
319
526
|
this.logger.logDebug(
|
|
320
527
|
`Bulk created ${results.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
321
528
|
);
|
|
@@ -333,9 +540,27 @@ export class Repository<T> {
|
|
|
333
540
|
* ```
|
|
334
541
|
*/
|
|
335
542
|
async update(id: number | string, entity: Partial<T>): Promise<T> {
|
|
336
|
-
return this.client.transaction((txClient) =>
|
|
337
|
-
this.
|
|
338
|
-
|
|
543
|
+
return this.client.transaction(async (txClient) => {
|
|
544
|
+
const before = await this.findOne(id, {}, txClient);
|
|
545
|
+
if (!before) throw new StabilizeError("Not found", "UPDATE_ERROR");
|
|
546
|
+
const instance = new (Object.getPrototypeOf(before).constructor || Object)();
|
|
547
|
+
Object.assign(instance, before, entity);
|
|
548
|
+
|
|
549
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
550
|
+
await this.runHooks(instance, "beforeSave");
|
|
551
|
+
|
|
552
|
+
const result = await this._update(id, entity, txClient);
|
|
553
|
+
|
|
554
|
+
await this.runHooks(result, "afterUpdate");
|
|
555
|
+
await this.runHooks(result, "afterSave");
|
|
556
|
+
|
|
557
|
+
await this.writeHistory(
|
|
558
|
+
{ ...before, ...entity, version: (before as any).version ? (before as any).version + 1 : 1 },
|
|
559
|
+
"update",
|
|
560
|
+
txClient
|
|
561
|
+
);
|
|
562
|
+
return result;
|
|
563
|
+
});
|
|
339
564
|
}
|
|
340
565
|
|
|
341
566
|
/**
|
|
@@ -417,14 +642,48 @@ export class Repository<T> {
|
|
|
417
642
|
for (let i = 0; i < updates.length; i += batchSize) {
|
|
418
643
|
const batch = updates.slice(i, i + batchSize);
|
|
419
644
|
for (const update of batch) {
|
|
420
|
-
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
645
|
+
// Find all IDs matching the where clause
|
|
646
|
+
const rows = await client.query<{ id: number | string }>(
|
|
647
|
+
`SELECT id FROM ${this.table} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`,
|
|
648
|
+
update.where.params,
|
|
649
|
+
);
|
|
650
|
+
for (const { id } of rows) {
|
|
651
|
+
// Fetch record before update for versioning
|
|
652
|
+
const before = await this.findOne(id, {}, client);
|
|
653
|
+
if (!before) continue;
|
|
654
|
+
|
|
655
|
+
// Prepare instance for hooks
|
|
656
|
+
const instance = new ((this as any).model || Object)();
|
|
657
|
+
Object.assign(instance, before, update.set);
|
|
658
|
+
|
|
659
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
660
|
+
await this.runHooks(instance, "beforeSave");
|
|
661
|
+
|
|
662
|
+
const keys = Object.keys(update.set).filter((k) => this.columns[k]);
|
|
663
|
+
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
664
|
+
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
665
|
+
const params = [
|
|
666
|
+
...keys.map((k) => (update.set as any)[k]),
|
|
667
|
+
id,
|
|
668
|
+
];
|
|
669
|
+
await client.query(query, params);
|
|
670
|
+
|
|
671
|
+
const after = await this.findOne(id, {}, client);
|
|
672
|
+
if (after) {
|
|
673
|
+
await this.runHooks(after, "afterUpdate");
|
|
674
|
+
await this.runHooks(after, "afterSave");
|
|
675
|
+
if (this.versioned) {
|
|
676
|
+
await this.writeHistory(
|
|
677
|
+
{
|
|
678
|
+
...after,
|
|
679
|
+
version: (before as any).version ? (before as any).version + 1 : 1
|
|
680
|
+
},
|
|
681
|
+
"update",
|
|
682
|
+
client
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
428
687
|
}
|
|
429
688
|
}
|
|
430
689
|
|
|
@@ -434,7 +693,6 @@ export class Repository<T> {
|
|
|
434
693
|
`Bulk updated ${updates.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
435
694
|
);
|
|
436
695
|
}
|
|
437
|
-
|
|
438
696
|
/**
|
|
439
697
|
* Performs an "update or insert" operation based on a set of unique keys.
|
|
440
698
|
* @param entity The entity to upsert.
|
|
@@ -479,6 +737,32 @@ export class Repository<T> {
|
|
|
479
737
|
const insertParams = columns.map((k) => (entity as any)[k]);
|
|
480
738
|
let params = [...insertParams, ...updateParams];
|
|
481
739
|
|
|
740
|
+
// Try to find the record before upsert
|
|
741
|
+
let before: T | null = null;
|
|
742
|
+
let isUpdate = false;
|
|
743
|
+
if (this.versioned && keys.length > 0) {
|
|
744
|
+
const whereClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(" AND ");
|
|
745
|
+
const whereParams = keys.map((k) => (entity as any)[k]);
|
|
746
|
+
const found = await client.query<T>(
|
|
747
|
+
`SELECT * FROM ${this.table} WHERE ${whereClause} LIMIT 1`,
|
|
748
|
+
whereParams
|
|
749
|
+
);
|
|
750
|
+
before = found[0] || null;
|
|
751
|
+
isUpdate = !!before;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Prepare instance for hooks
|
|
755
|
+
const instance = new ((this as any).model || Object)();
|
|
756
|
+
Object.assign(instance, before || {}, entity);
|
|
757
|
+
|
|
758
|
+
if (isUpdate) {
|
|
759
|
+
await this.runHooks(instance, "beforeUpdate");
|
|
760
|
+
await this.runHooks(instance, "beforeSave");
|
|
761
|
+
} else {
|
|
762
|
+
await this.runHooks(instance, "beforeCreate");
|
|
763
|
+
await this.runHooks(instance, "beforeSave");
|
|
764
|
+
}
|
|
765
|
+
|
|
482
766
|
if (dbType === DBType.SQLite) {
|
|
483
767
|
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
|
|
484
768
|
} else if (dbType === DBType.MySQL) {
|
|
@@ -495,7 +779,7 @@ export class Repository<T> {
|
|
|
495
779
|
let id: number | string | undefined = (results[0] as any)?.id || (entity as any).id;
|
|
496
780
|
|
|
497
781
|
if (!id && dbType !== DBType.Postgres) {
|
|
498
|
-
|
|
782
|
+
if (dbType === DBType.SQLite) {
|
|
499
783
|
id = (await client.query<{ id: number }>("SELECT last_insert_rowid() as id"))[0]?.id;
|
|
500
784
|
} else if (dbType === DBType.MySQL) {
|
|
501
785
|
const result = await client.query<{ "LAST_INSERT_ID()": number }>("SELECT LAST_INSERT_ID()");
|
|
@@ -507,19 +791,34 @@ export class Repository<T> {
|
|
|
507
791
|
|
|
508
792
|
const result = results[0] ?? ((await this.findOne(id, {}, client)) as T);
|
|
509
793
|
|
|
794
|
+
if (isUpdate) {
|
|
795
|
+
await this.runHooks(result, "afterUpdate");
|
|
796
|
+
await this.runHooks(result, "afterSave");
|
|
797
|
+
} else {
|
|
798
|
+
await this.runHooks(result, "afterCreate");
|
|
799
|
+
await this.runHooks(result, "afterSave");
|
|
800
|
+
}
|
|
801
|
+
|
|
510
802
|
if (this.cache) {
|
|
511
803
|
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
512
804
|
if (this.cache.getStrategy() === "write-through") {
|
|
513
805
|
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
514
806
|
}
|
|
515
807
|
}
|
|
516
|
-
|
|
808
|
+
|
|
809
|
+
if (this.versioned) {
|
|
810
|
+
await this.writeHistory(
|
|
811
|
+
{ ...result, version: before ? ((before as any).version ? (before as any).version + 1 : 1) : 1 },
|
|
812
|
+
before ? "update" : "insert",
|
|
813
|
+
client
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
517
817
|
this.logger.logDebug(
|
|
518
818
|
`Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
519
819
|
);
|
|
520
820
|
return result;
|
|
521
821
|
}
|
|
522
|
-
|
|
523
822
|
/**
|
|
524
823
|
* Deletes a record by its ID. Performs a soft delete if enabled on the model.
|
|
525
824
|
* @param id The ID of the record to delete.
|
|
@@ -530,7 +829,16 @@ export class Repository<T> {
|
|
|
530
829
|
* ```
|
|
531
830
|
*/
|
|
532
831
|
async delete(id: number | string): Promise<void> {
|
|
533
|
-
return this.client.transaction((txClient) =>
|
|
832
|
+
return this.client.transaction(async (txClient) => {
|
|
833
|
+
const before = await this.findOne(id, {}, txClient);
|
|
834
|
+
if (!before) throw new StabilizeError("Not found", "DELETE_ERROR");
|
|
835
|
+
await this.runHooks(before, "beforeDelete");
|
|
836
|
+
|
|
837
|
+
await this._delete(id, txClient);
|
|
838
|
+
|
|
839
|
+
await this.runHooks(before, "afterDelete");
|
|
840
|
+
await this.writeHistory(before, "delete", txClient);
|
|
841
|
+
});
|
|
534
842
|
}
|
|
535
843
|
|
|
536
844
|
/**
|
|
@@ -545,7 +853,7 @@ export class Repository<T> {
|
|
|
545
853
|
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
546
854
|
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
547
855
|
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
548
|
-
|
|
856
|
+
|
|
549
857
|
await client.query(query, params);
|
|
550
858
|
|
|
551
859
|
if (this.cache) {
|
|
@@ -594,14 +902,25 @@ export class Repository<T> {
|
|
|
594
902
|
const batchSize = options.batchSize || 1000;
|
|
595
903
|
for (let i = 0; i < ids.length; i += batchSize) {
|
|
596
904
|
const batch = ids.slice(i, i + batchSize);
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
905
|
+
for (const id of batch) {
|
|
906
|
+
const before = await this.findOne(id, {}, client);
|
|
907
|
+
if (!before) continue;
|
|
908
|
+
|
|
909
|
+
await this.runHooks(before, "beforeDelete");
|
|
910
|
+
|
|
911
|
+
const query = this.softDeleteField
|
|
912
|
+
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
913
|
+
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
914
|
+
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
915
|
+
|
|
916
|
+
await client.query(query, params);
|
|
917
|
+
|
|
918
|
+
await this.runHooks(before, "afterDelete");
|
|
919
|
+
|
|
920
|
+
if (this.versioned) {
|
|
921
|
+
await this.writeHistory(before, "delete", client);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
605
924
|
}
|
|
606
925
|
|
|
607
926
|
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
@@ -610,7 +929,6 @@ export class Repository<T> {
|
|
|
610
929
|
`Bulk deleted ${ids.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
611
930
|
);
|
|
612
931
|
}
|
|
613
|
-
|
|
614
932
|
/**
|
|
615
933
|
* Recovers a soft-deleted record by its ID.
|
|
616
934
|
* Throws an error if soft delete is not enabled on the model.
|
|
@@ -638,7 +956,7 @@ export class Repository<T> {
|
|
|
638
956
|
"RECOVER_ERROR",
|
|
639
957
|
);
|
|
640
958
|
}
|
|
641
|
-
|
|
959
|
+
|
|
642
960
|
await client.query(
|
|
643
961
|
`UPDATE ${this.table} SET ${this.softDeleteField} = NULL WHERE id = ?`,
|
|
644
962
|
[id],
|
|
@@ -653,7 +971,7 @@ export class Repository<T> {
|
|
|
653
971
|
`findOne:${this.table}:${id}`,
|
|
654
972
|
]);
|
|
655
973
|
}
|
|
656
|
-
|
|
974
|
+
|
|
657
975
|
this.logger.logDebug(
|
|
658
976
|
`Recovered ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
659
977
|
);
|