stabilize-orm 1.3.8 → 2.1.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 +1097 -546
- package/dist/auto-migrate.d.ts +34 -0
- package/dist/auto-migrate.d.ts.map +1 -0
- package/dist/auto-migrate.js +3003 -0
- package/dist/auto-migrate.js.map +163 -0
- package/dist/cache.d.ts +90 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +166 -0
- package/dist/cache.js.map +64 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2997 -0
- package/dist/client.js.map +162 -0
- package/dist/hooks.d.ts +31 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +4 -0
- package/dist/hooks.js.map +11 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3183 -0
- package/dist/index.js.map +222 -0
- package/dist/logger.d.ts +40 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +8 -0
- package/dist/logger.js.map +11 -0
- package/dist/migrations.d.ts +31 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +3009 -0
- package/dist/migrations.js.map +164 -0
- package/{model.ts → dist/model.d.ts} +124 -189
- package/dist/model.d.ts.map +1 -0
- package/dist/model.js +4 -0
- package/dist/model.js.map +10 -0
- package/dist/query-builder.d.ts +91 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/query-builder.js +14 -0
- package/dist/query-builder.js.map +12 -0
- package/dist/repository.d.ts +165 -0
- package/dist/repository.d.ts.map +1 -0
- package/dist/repository.js +176 -0
- package/dist/repository.js.map +69 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +10 -0
- package/dist/utils/encryption.d.ts +13 -0
- package/dist/utils/encryption.d.ts.map +1 -0
- package/dist/utils/encryption.js +4 -0
- package/dist/utils/encryption.js.map +10 -0
- package/package.json +104 -25
- package/.eslintrc.json +0 -10
- package/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md +0 -23
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -25
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -17
- package/.github/workflows/ci-cd.yml +0 -22
- package/CHANGELOG.md +0 -75
- package/CODE_OF_CONDUCT.md +0 -87
- package/CONTRIBUTING.md +0 -48
- package/FUNDING.md +0 -14
- package/SECURITY.md +0 -35
- package/SUPPORT.md +0 -18
- package/bun.lock +0 -667
- package/cache.ts +0 -181
- package/client.ts +0 -249
- package/docker-compose.yml +0 -22
- package/hooks.ts +0 -76
- package/index.ts +0 -158
- package/logger.ts +0 -127
- package/migrations.ts +0 -318
- package/query-builder.ts +0 -209
- package/repository.ts +0 -1096
- package/tests/migrations.test.ts +0 -141
- package/tsconfig.json +0 -32
- package/types.ts +0 -106
package/repository.ts
DELETED
|
@@ -1,1096 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
import { Cache } from "./cache";
|
|
8
|
-
import { DBClient } from "./client";
|
|
9
|
-
import { StabilizeLogger, type Logger } from "./logger";
|
|
10
|
-
import { QueryBuilder } from "./query-builder";
|
|
11
|
-
import {
|
|
12
|
-
DataTypes,
|
|
13
|
-
DBType,
|
|
14
|
-
RelationType,
|
|
15
|
-
StabilizeError,
|
|
16
|
-
type CacheConfig,
|
|
17
|
-
} from "./types";
|
|
18
|
-
import { MetadataStorage } from "./model";
|
|
19
|
-
import { getHooks, type HookType } from "./hooks";
|
|
20
|
-
|
|
21
|
-
type VersionOperation = "insert" | "update" | "delete";
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Provides a generic repository for a model `T`.
|
|
25
|
-
* This class abstracts the database interactions for a specific model,
|
|
26
|
-
* offering methods for creating, reading, updating, and deleting records.
|
|
27
|
-
* @template T The model entity type.
|
|
28
|
-
*/
|
|
29
|
-
export class Repository<T> {
|
|
30
|
-
private client: DBClient;
|
|
31
|
-
private cache: Cache | null;
|
|
32
|
-
private table: string;
|
|
33
|
-
private columns: Record<string, { name: string; type: string }>;
|
|
34
|
-
private validators: Record<string, string[]>;
|
|
35
|
-
private relations: Record<
|
|
36
|
-
string,
|
|
37
|
-
{
|
|
38
|
-
type: RelationType;
|
|
39
|
-
targetModel: () => any;
|
|
40
|
-
foreignKey?: string;
|
|
41
|
-
inverseKey?: string;
|
|
42
|
-
joinTable?: string;
|
|
43
|
-
}
|
|
44
|
-
>;
|
|
45
|
-
private softDeleteField: string | null;
|
|
46
|
-
private logger: Logger;
|
|
47
|
-
private versioned: boolean;
|
|
48
|
-
private historyTable: string;
|
|
49
|
-
private model: new (...args: any[]) => T;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Creates an instance of Repository.
|
|
53
|
-
* @param client The database client instance for executing queries.
|
|
54
|
-
* @param model The model class constructor, defined with `defineModel`.
|
|
55
|
-
* @param cacheConfig Optional configuration for caching.
|
|
56
|
-
* @param logger A logger instance for logging messages.
|
|
57
|
-
*/
|
|
58
|
-
constructor(
|
|
59
|
-
client: DBClient,
|
|
60
|
-
model: new (...args: any[]) => T,
|
|
61
|
-
cacheConfig: CacheConfig = { enabled: false, ttl: 60 },
|
|
62
|
-
logger: Logger = new StabilizeLogger(),
|
|
63
|
-
) {
|
|
64
|
-
this.client = client;
|
|
65
|
-
this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
|
|
66
|
-
this.table = MetadataStorage.getTableName(model);
|
|
67
|
-
this.columns = Object.fromEntries(
|
|
68
|
-
Object.entries(MetadataStorage.getColumns(model)).map(([key, col]) => [
|
|
69
|
-
key,
|
|
70
|
-
{ name: col.name ?? key, type: typeof col.type === 'string' ? col.type : DataTypes[col.type] },
|
|
71
|
-
])
|
|
72
|
-
);
|
|
73
|
-
this.relations = Object.fromEntries(
|
|
74
|
-
Object.entries(MetadataStorage.getRelations(model)).map(([key, rel]) => [
|
|
75
|
-
key,
|
|
76
|
-
{
|
|
77
|
-
type: rel.type,
|
|
78
|
-
targetModel: rel.target,
|
|
79
|
-
foreignKey: rel.foreignKey,
|
|
80
|
-
inverseKey: rel.inverseKey,
|
|
81
|
-
joinTable: rel.joinTable,
|
|
82
|
-
},
|
|
83
|
-
])
|
|
84
|
-
);
|
|
85
|
-
this.validators = MetadataStorage.getValidators(model);
|
|
86
|
-
|
|
87
|
-
this.softDeleteField = MetadataStorage.getSoftDeleteField(model);
|
|
88
|
-
this.logger = logger;
|
|
89
|
-
this.versioned = MetadataStorage.isVersioned(model);
|
|
90
|
-
this.historyTable = `${this.table}_history`;
|
|
91
|
-
this.model = model;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* @internal
|
|
96
|
-
* Gets the database type from the client's configuration.
|
|
97
|
-
* @param _client An optional client instance (used in transactions).
|
|
98
|
-
* @returns The `DBType` enum for the current database.
|
|
99
|
-
*/
|
|
100
|
-
private getDBType(_client?: DBClient): DBType {
|
|
101
|
-
const client = _client || this.client;
|
|
102
|
-
return client.config.type;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* @internal
|
|
107
|
-
* Validates an entity against the 'required' constraints defined in the model configuration.
|
|
108
|
-
* @param entity The partial entity to validate.
|
|
109
|
-
*/
|
|
110
|
-
private validate(entity: Partial<T>) {
|
|
111
|
-
for (const [key, rules] of Object.entries(this.validators)) {
|
|
112
|
-
const value = (entity as any)[key];
|
|
113
|
-
if (
|
|
114
|
-
rules.includes("required") &&
|
|
115
|
-
(value === undefined || value === null)
|
|
116
|
-
) {
|
|
117
|
-
throw new StabilizeError(
|
|
118
|
-
`Field ${key} is required`,
|
|
119
|
-
"VALIDATION_ERROR",
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Runs lifecycle hooks of a given type for the entity.
|
|
127
|
-
* @param entity The entity instance.
|
|
128
|
-
* @param type The hook type (e.g., 'beforeCreate').
|
|
129
|
-
*/
|
|
130
|
-
private async runHooks(entity: any, type: HookType): Promise<void> {
|
|
131
|
-
for (const hook of getHooks(entity, type)) {
|
|
132
|
-
await hook.callback(entity);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* Creates a new `QueryBuilder` instance for the repository's table.
|
|
138
|
-
* Automatically adds a `WHERE` clause to exclude soft-deleted records if applicable.
|
|
139
|
-
* @returns A `QueryBuilder` instance for constructing a query.
|
|
140
|
-
* @example
|
|
141
|
-
* ```
|
|
142
|
-
* const activeUsersQuery = userRepository.find().where('status = ?', 'active');
|
|
143
|
-
* ```
|
|
144
|
-
*/
|
|
145
|
-
find(): QueryBuilder<T> {
|
|
146
|
-
const qb = new QueryBuilder<T>(this.table);
|
|
147
|
-
if (this.softDeleteField) {
|
|
148
|
-
qb.where(`${this.softDeleteField} IS NULL`);
|
|
149
|
-
}
|
|
150
|
-
return qb;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* Applies a custom scope to the query for the repository's table.
|
|
155
|
-
* @param name The name of the scope to apply.
|
|
156
|
-
* @param args Optional arguments to pass to the scope function.
|
|
157
|
-
* @returns A `QueryBuilder` instance with the scope applied.
|
|
158
|
-
* @example
|
|
159
|
-
* ```
|
|
160
|
-
* const activeUsers = await userRepository.scope('active').execute(client);
|
|
161
|
-
* ```
|
|
162
|
-
*/
|
|
163
|
-
scope(name: string, ...args: any[]): QueryBuilder<T> {
|
|
164
|
-
this.logger.logDebug(`Applying scope ${name} to ${this.table}`);
|
|
165
|
-
return this.find().scope(name, ...args);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Finds a single record by its primary key (id).
|
|
170
|
-
* @param id The ID of the record to find.
|
|
171
|
-
* @param options Optional: Specify relations to load.
|
|
172
|
-
* @param _client Optional: An internal client for transactions.
|
|
173
|
-
* @returns A promise that resolves to the entity or `null` if not found.
|
|
174
|
-
* @example
|
|
175
|
-
* ```
|
|
176
|
-
* const user = await userRepository.findOne(1);
|
|
177
|
-
* ```
|
|
178
|
-
*/
|
|
179
|
-
async findOne(
|
|
180
|
-
id: number | string,
|
|
181
|
-
options: { relations?: string[] } = {},
|
|
182
|
-
_client?: DBClient,
|
|
183
|
-
): Promise<T | null> {
|
|
184
|
-
const client = _client || this.client;
|
|
185
|
-
const start = performance.now();
|
|
186
|
-
this.logger.logDebug(`Finding one ${this.table} with ID ${id}`);
|
|
187
|
-
const queryBuilder = this.find().where("id = ?", id).limit(1);
|
|
188
|
-
if (options.relations) {
|
|
189
|
-
for (const rel of options.relations) {
|
|
190
|
-
await this.loadRelation(queryBuilder, rel);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
const cacheKey = `findOne:${this.table}:${id}:${options.relations?.join(",")}`;
|
|
194
|
-
const results = await queryBuilder.execute(client, this.cache!, cacheKey);
|
|
195
|
-
this.logger.logDebug(
|
|
196
|
-
`Found ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
197
|
-
);
|
|
198
|
-
return results[0] || null;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Snapshot query: get record as it was at a point in time.
|
|
203
|
-
*/
|
|
204
|
-
async asOf(
|
|
205
|
-
id: number | string,
|
|
206
|
-
asOfDate: Date,
|
|
207
|
-
_client?: DBClient
|
|
208
|
-
): Promise<T | null> {
|
|
209
|
-
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
210
|
-
const client = _client || this.client;
|
|
211
|
-
const rows = await client.query<T>(
|
|
212
|
-
`SELECT * FROM ${this.historyTable} WHERE id = ? AND valid_from <= ? AND (valid_to IS NULL OR valid_to > ?) ORDER BY version DESC LIMIT 1`,
|
|
213
|
-
[id, asOfDate, asOfDate]
|
|
214
|
-
);
|
|
215
|
-
return rows[0] || null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Get all history for a record.
|
|
220
|
-
*/
|
|
221
|
-
async history(
|
|
222
|
-
id: number | string,
|
|
223
|
-
_client?: DBClient
|
|
224
|
-
): Promise<T[]> {
|
|
225
|
-
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
226
|
-
const client = _client || this.client;
|
|
227
|
-
return client.query<T>(
|
|
228
|
-
`SELECT * FROM ${this.historyTable} WHERE id = ? ORDER BY version ASC`,
|
|
229
|
-
[id]
|
|
230
|
-
);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Rollback a record to a previous version.
|
|
235
|
-
*/
|
|
236
|
-
async rollback(
|
|
237
|
-
id: number | string,
|
|
238
|
-
version: number,
|
|
239
|
-
_client?: DBClient
|
|
240
|
-
): Promise<T> {
|
|
241
|
-
if (!this.versioned) throw new StabilizeError("Model is not versioned", "VERSIONING_ERROR");
|
|
242
|
-
const client = _client || this.client;
|
|
243
|
-
return client.transaction(async (txClient) => {
|
|
244
|
-
const rows = await txClient.query<T>(
|
|
245
|
-
`SELECT * FROM ${this.historyTable} WHERE id = ? AND version = ? LIMIT 1`,
|
|
246
|
-
[id, version]
|
|
247
|
-
);
|
|
248
|
-
if (!rows.length) throw new StabilizeError("Version not found", "ROLLBACK_ERROR");
|
|
249
|
-
|
|
250
|
-
const entity = rows[0];
|
|
251
|
-
const columns = Object.keys(this.columns).filter((c) => c !== "id");
|
|
252
|
-
const setClause = columns.map((c) => `${this.columns[c]!.name} = ?`).join(", ");
|
|
253
|
-
const params = columns.map((c) => (entity as any)[c]);
|
|
254
|
-
|
|
255
|
-
await txClient.query(
|
|
256
|
-
`UPDATE ${this.table} SET ${setClause} WHERE id = ?`,
|
|
257
|
-
[...params, id]
|
|
258
|
-
);
|
|
259
|
-
await this.writeHistory({ ...entity, version: version + 1 }, "update", txClient);
|
|
260
|
-
return this.findOne(id, {}, txClient) as Promise<T>;
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/**
|
|
265
|
-
* Writes a versioned history row for the entity to the history table.
|
|
266
|
-
* @param entity The entity object being versioned
|
|
267
|
-
* @param operation The operation performed ("insert", "update", "delete")
|
|
268
|
-
* @param client The database client to use for the insert
|
|
269
|
-
* @param user The user/system responsible for the change (default: "system")
|
|
270
|
-
*/
|
|
271
|
-
private async writeHistory(
|
|
272
|
-
entity: any,
|
|
273
|
-
operation: VersionOperation,
|
|
274
|
-
client: DBClient,
|
|
275
|
-
user?: string
|
|
276
|
-
) {
|
|
277
|
-
if (!this.versioned) return;
|
|
278
|
-
|
|
279
|
-
const propertyKeys = Object.keys(this.columns);
|
|
280
|
-
const sqlColumnNames = propertyKeys.map((k) => this.columns[k]!.name);
|
|
281
|
-
|
|
282
|
-
const historyColumns = [
|
|
283
|
-
...sqlColumnNames,
|
|
284
|
-
"operation",
|
|
285
|
-
"version",
|
|
286
|
-
"valid_from",
|
|
287
|
-
"valid_to",
|
|
288
|
-
"modified_by",
|
|
289
|
-
"modified_at"
|
|
290
|
-
];
|
|
291
|
-
|
|
292
|
-
// Helper to sanitize each value before inserting into SQLite/Postgres/MySQL
|
|
293
|
-
function sanitizeSqlValue(val: any, dbType: DBType): string | number | boolean | bigint | null {
|
|
294
|
-
if (val === undefined) return null;
|
|
295
|
-
if (val instanceof Date) {
|
|
296
|
-
if (dbType === DBType.MySQL) {
|
|
297
|
-
// MySQL DATETIME: 'YYYY-MM-DD HH:MM:SS'
|
|
298
|
-
return val.toISOString().slice(0, 19).replace('T', ' ');
|
|
299
|
-
}
|
|
300
|
-
return val.toISOString();
|
|
301
|
-
}
|
|
302
|
-
if (typeof val === "boolean") return val ? 1 : 0;
|
|
303
|
-
if (
|
|
304
|
-
typeof val === "string" ||
|
|
305
|
-
typeof val === "number" ||
|
|
306
|
-
typeof val === "bigint"
|
|
307
|
-
) return val;
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
const dbType = client.config.type;
|
|
311
|
-
const values = propertyKeys.map((k) => sanitizeSqlValue(entity[k], dbType));
|
|
312
|
-
const params = [
|
|
313
|
-
...values,
|
|
314
|
-
sanitizeSqlValue(operation, dbType),
|
|
315
|
-
sanitizeSqlValue(entity.version || 1, dbType),
|
|
316
|
-
sanitizeSqlValue(new Date(), dbType),
|
|
317
|
-
sanitizeSqlValue(null, dbType),
|
|
318
|
-
sanitizeSqlValue(user || "system", dbType),
|
|
319
|
-
sanitizeSqlValue(new Date(), dbType)
|
|
320
|
-
];
|
|
321
|
-
|
|
322
|
-
let placeholders: string;
|
|
323
|
-
if (client.config.type === DBType.Postgres) {
|
|
324
|
-
placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
|
|
325
|
-
} else {
|
|
326
|
-
placeholders = params.map(() => "?").join(", ");
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
await client.query(
|
|
330
|
-
`INSERT INTO ${this.historyTable} (${historyColumns.join(", ")}) VALUES (${placeholders})`,
|
|
331
|
-
params
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Creates a new record in the database within a transaction.
|
|
337
|
-
* @param entity The data for the new record.
|
|
338
|
-
* @param options Optional: Specify relations to load on the returned entity.
|
|
339
|
-
* @returns A promise that resolves to the newly created entity.
|
|
340
|
-
* @example
|
|
341
|
-
* ```
|
|
342
|
-
* const newUser = await userRepository.create({ name: 'Ciniso Dlamini', email: 'lwazicd@icloud.com' });
|
|
343
|
-
* ```
|
|
344
|
-
*/
|
|
345
|
-
async create(
|
|
346
|
-
entity: Partial<T>,
|
|
347
|
-
options: { relations?: string[] } = {},
|
|
348
|
-
): Promise<T> {
|
|
349
|
-
return this.client.transaction(async (txClient) => {
|
|
350
|
-
const instance = new (Object.getPrototypeOf(entity).constructor || Object)();
|
|
351
|
-
Object.assign(instance, entity);
|
|
352
|
-
|
|
353
|
-
await this.runHooks(instance, "beforeCreate");
|
|
354
|
-
await this.runHooks(instance, "beforeSave");
|
|
355
|
-
|
|
356
|
-
const result = await this._create(entity, options, txClient);
|
|
357
|
-
|
|
358
|
-
await this.runHooks(result, "afterCreate");
|
|
359
|
-
await this.runHooks(result, "afterSave");
|
|
360
|
-
|
|
361
|
-
await this.writeHistory(result, "insert", txClient);
|
|
362
|
-
return result;
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* @internal
|
|
368
|
-
* The private implementation for creating a record, executed within a transaction.
|
|
369
|
-
*/
|
|
370
|
-
private async _create(
|
|
371
|
-
entity: Partial<T>,
|
|
372
|
-
options: { relations?: string[] },
|
|
373
|
-
client: DBClient,
|
|
374
|
-
): Promise<T> {
|
|
375
|
-
const start = performance.now();
|
|
376
|
-
this.logger.logDebug(
|
|
377
|
-
`Creating ${this.table} with data: ${JSON.stringify(entity)}`,
|
|
378
|
-
);
|
|
379
|
-
this.validate(entity);
|
|
380
|
-
|
|
381
|
-
const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
|
|
382
|
-
const entityWithTimestamps = { ...entity } as Record<string, any>;
|
|
383
|
-
if (timestamps.createdAt && !entityWithTimestamps[timestamps.createdAt]) {
|
|
384
|
-
entityWithTimestamps[timestamps.createdAt] = new Date();
|
|
385
|
-
}
|
|
386
|
-
if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
|
|
387
|
-
entityWithTimestamps[timestamps.updatedAt] = new Date();
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
|
|
391
|
-
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
392
|
-
const placeholders = keys.map(() => "?").join(", ");
|
|
393
|
-
const params = keys.map((k) => (entityWithTimestamps as any)[k]);
|
|
394
|
-
let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
|
|
395
|
-
|
|
396
|
-
let insertedResult: T[] | undefined;
|
|
397
|
-
let id: number | string | undefined;
|
|
398
|
-
const dbType = this.getDBType(client);
|
|
399
|
-
|
|
400
|
-
if (dbType === DBType.Postgres) {
|
|
401
|
-
query += " RETURNING *";
|
|
402
|
-
insertedResult = await client.query<T>(query, params);
|
|
403
|
-
id = (insertedResult?.[0] as any)?.id;
|
|
404
|
-
} else {
|
|
405
|
-
await client.query(query, params);
|
|
406
|
-
if (dbType === DBType.SQLite) {
|
|
407
|
-
id = (await client.query<{ id: number }>("SELECT last_insert_rowid() as id"))[0]?.id;
|
|
408
|
-
} else if (dbType === DBType.MySQL) {
|
|
409
|
-
const result = await client.query<{ "LAST_INSERT_ID()": number }>("SELECT LAST_INSERT_ID()");
|
|
410
|
-
id = result[0]?.["LAST_INSERT_ID()"];
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (!id) throw new StabilizeError("Failed to retrieve inserted ID", "INSERT_ERROR");
|
|
415
|
-
|
|
416
|
-
const result = insertedResult?.[0] ?? ((await this.findOne(id, options, client)) as T);
|
|
417
|
-
|
|
418
|
-
if (this.cache) {
|
|
419
|
-
const cacheKeys = [`find:${this.table}`, `findOne:${this.table}:${id}`];
|
|
420
|
-
await this.cache.invalidate(cacheKeys);
|
|
421
|
-
if (this.cache.getStrategy() === "write-through") {
|
|
422
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
this.logger.logDebug(
|
|
427
|
-
`Created ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
428
|
-
);
|
|
429
|
-
return result;
|
|
430
|
-
}
|
|
431
|
-
/**
|
|
432
|
-
* Creates multiple records in the database in batches.
|
|
433
|
-
* @param entities An array of entities to create.
|
|
434
|
-
* @param options Optional: Specify relations or batch size.
|
|
435
|
-
* @returns A promise that resolves to an array of the newly created entities.
|
|
436
|
-
* @example
|
|
437
|
-
* ```
|
|
438
|
-
* const newUsers = await userRepository.bulkCreate([
|
|
439
|
-
* { name: 'Ciniso' },
|
|
440
|
-
* { name: 'Lwazi' }
|
|
441
|
-
* ]);
|
|
442
|
-
* ```
|
|
443
|
-
*/
|
|
444
|
-
async bulkCreate(
|
|
445
|
-
entities: Partial<T>[],
|
|
446
|
-
options: { relations?: string[]; batchSize?: number } = {},
|
|
447
|
-
): Promise<T[]> {
|
|
448
|
-
return this.client.transaction(async (txClient) => {
|
|
449
|
-
const preparedEntities = entities.map(data => {
|
|
450
|
-
const instance = new (this as any).model();
|
|
451
|
-
Object.assign(instance, data);
|
|
452
|
-
return instance;
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
for (const entity of preparedEntities) {
|
|
456
|
-
await this.runHooks(entity, "beforeCreate");
|
|
457
|
-
await this.runHooks(entity, "beforeSave");
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const results = await this._bulkCreate(entities, options, txClient);
|
|
461
|
-
|
|
462
|
-
for (const result of results) {
|
|
463
|
-
await this.runHooks(result, "afterCreate");
|
|
464
|
-
await this.runHooks(result, "afterSave");
|
|
465
|
-
if (this.versioned) {
|
|
466
|
-
await this.writeHistory(result, "insert", txClient);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
return results;
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
/**
|
|
474
|
-
* @internal
|
|
475
|
-
* The private implementation for bulk creating records, executed within a transaction.
|
|
476
|
-
*/
|
|
477
|
-
private async _bulkCreate(
|
|
478
|
-
entities: Partial<T>[],
|
|
479
|
-
options: { relations?: string[]; batchSize?: number },
|
|
480
|
-
client: DBClient,
|
|
481
|
-
): Promise<T[]> {
|
|
482
|
-
const start = performance.now();
|
|
483
|
-
this.logger.logDebug(
|
|
484
|
-
`Bulk creating ${entities.length} ${this.table} entities`,
|
|
485
|
-
);
|
|
486
|
-
if (!entities.length) return [];
|
|
487
|
-
|
|
488
|
-
const batchSize = options.batchSize || 1000;
|
|
489
|
-
entities.forEach((entity) => this.validate(entity));
|
|
490
|
-
|
|
491
|
-
const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
|
|
492
|
-
const entitiesWithTimestamps = entities.map(entity => ({
|
|
493
|
-
...entity,
|
|
494
|
-
...(timestamps.createdAt && !(entity as Record<string, any>)[timestamps.createdAt] ? { [timestamps.createdAt]: new Date() } : {}),
|
|
495
|
-
...(timestamps.updatedAt && !(entity as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
|
|
496
|
-
})) as Partial<T>[];
|
|
497
|
-
|
|
498
|
-
const dbType = this.getDBType(client);
|
|
499
|
-
const results: T[] = [];
|
|
500
|
-
|
|
501
|
-
for (let i = 0; i < entitiesWithTimestamps.length; i += batchSize) {
|
|
502
|
-
const batch = entitiesWithTimestamps.slice(i, i + batchSize);
|
|
503
|
-
const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
|
|
504
|
-
const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
|
|
505
|
-
|
|
506
|
-
let query: string;
|
|
507
|
-
let params: any[] = batch.flatMap((entity) =>
|
|
508
|
-
keys.map((k) => (entity as any)[k]),
|
|
509
|
-
);
|
|
510
|
-
|
|
511
|
-
if (dbType === DBType.Postgres) {
|
|
512
|
-
let paramIdx = 1;
|
|
513
|
-
const valuePlaceholders = batch
|
|
514
|
-
.map(
|
|
515
|
-
() =>
|
|
516
|
-
`(${keys.map(() => `$${paramIdx++}`).join(", ")})`
|
|
517
|
-
)
|
|
518
|
-
.join(", ");
|
|
519
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${valuePlaceholders} RETURNING *`;
|
|
520
|
-
const batchResults = await client.query<T>(query, params);
|
|
521
|
-
const ids = batchResults.map((r) => (r as any).id);
|
|
522
|
-
|
|
523
|
-
let finalResults = batchResults;
|
|
524
|
-
if (ids.length > 0 && batchResults.length === 0) {
|
|
525
|
-
const queryBuilder = this.find().where(
|
|
526
|
-
`id IN (${ids.map(() => "?").join(", ")})`,
|
|
527
|
-
...ids,
|
|
528
|
-
);
|
|
529
|
-
if (options.relations) {
|
|
530
|
-
for (const rel of options.relations) {
|
|
531
|
-
await this.loadRelation(queryBuilder, rel);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
finalResults = await queryBuilder.execute(client);
|
|
535
|
-
}
|
|
536
|
-
results.push(...finalResults);
|
|
537
|
-
} else {
|
|
538
|
-
const placeholders = `(${keys.map(() => "?").join(", ")})`;
|
|
539
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
|
|
540
|
-
await client.query(query, params);
|
|
541
|
-
const ids = (
|
|
542
|
-
await client.query<{ id: number }>(
|
|
543
|
-
`SELECT id FROM ${this.table} ORDER BY id DESC LIMIT ?`,
|
|
544
|
-
[batch.length],
|
|
545
|
-
)
|
|
546
|
-
).map((row) => row.id);
|
|
547
|
-
|
|
548
|
-
let batchResults: T[] = [];
|
|
549
|
-
if (ids.length > 0) {
|
|
550
|
-
const queryBuilder = this.find().where(
|
|
551
|
-
`id IN (${ids.map(() => "?").join(", ")})`,
|
|
552
|
-
...ids,
|
|
553
|
-
);
|
|
554
|
-
if (options.relations) {
|
|
555
|
-
for (const rel of options.relations) {
|
|
556
|
-
await this.loadRelation(queryBuilder, rel);
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
batchResults = await queryBuilder.execute(client);
|
|
560
|
-
}
|
|
561
|
-
results.push(...batchResults);
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
566
|
-
|
|
567
|
-
this.logger.logDebug(
|
|
568
|
-
`Bulk created ${results.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
569
|
-
);
|
|
570
|
-
return results;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/**
|
|
574
|
-
* Updates a record by its ID within a transaction.
|
|
575
|
-
* @param id The ID of the record to update.
|
|
576
|
-
* @param entity An object containing the fields to update.
|
|
577
|
-
* @returns A promise that resolves to the updated entity.
|
|
578
|
-
* @example
|
|
579
|
-
* ```
|
|
580
|
-
* const updatedUser = await userRepository.update(1, { name: 'Ciniso Dlamini' });
|
|
581
|
-
* ```
|
|
582
|
-
*/
|
|
583
|
-
async update(id: number | string, entity: Partial<T>): Promise<T> {
|
|
584
|
-
return this.client.transaction(async (txClient) => {
|
|
585
|
-
const before = await this.findOne(id, {}, txClient);
|
|
586
|
-
if (!before) throw new StabilizeError("Not found", "UPDATE_ERROR");
|
|
587
|
-
const instance = new (Object.getPrototypeOf(before).constructor || Object)();
|
|
588
|
-
Object.assign(instance, before, entity);
|
|
589
|
-
|
|
590
|
-
await this.runHooks(instance, "beforeUpdate");
|
|
591
|
-
await this.runHooks(instance, "beforeSave");
|
|
592
|
-
|
|
593
|
-
const result = await this._update(id, entity, txClient);
|
|
594
|
-
|
|
595
|
-
await this.runHooks(result, "afterUpdate");
|
|
596
|
-
await this.runHooks(result, "afterSave");
|
|
597
|
-
|
|
598
|
-
await this.writeHistory(
|
|
599
|
-
{ ...before, ...entity, version: (before as any).version ? (before as any).version + 1 : 1 },
|
|
600
|
-
"update",
|
|
601
|
-
txClient
|
|
602
|
-
);
|
|
603
|
-
return result;
|
|
604
|
-
});
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
/**
|
|
608
|
-
* @internal
|
|
609
|
-
* The private implementation for updating a record, executed within a transaction.
|
|
610
|
-
*/
|
|
611
|
-
private async _update(
|
|
612
|
-
id: number | string,
|
|
613
|
-
entity: Partial<T>,
|
|
614
|
-
client: DBClient,
|
|
615
|
-
): Promise<T> {
|
|
616
|
-
const start = performance.now();
|
|
617
|
-
this.logger.logDebug(`Updating ${this.table} with ID ${id}`);
|
|
618
|
-
this.validate(entity);
|
|
619
|
-
|
|
620
|
-
const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
|
|
621
|
-
const entityWithTimestamps = { ...entity } as Record<string, any>;;
|
|
622
|
-
if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
|
|
623
|
-
entityWithTimestamps[timestamps.updatedAt] = new Date();
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
|
|
627
|
-
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
628
|
-
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
629
|
-
const params = [...keys.map((k) => (entity as any)[k]), id];
|
|
630
|
-
await client.query(query, params);
|
|
631
|
-
|
|
632
|
-
const result = await this.findOne(id, {}, client);
|
|
633
|
-
if (!result) throw new StabilizeError("Failed to find updated record.", "UPDATE_ERROR");
|
|
634
|
-
|
|
635
|
-
if (this.cache) {
|
|
636
|
-
const cacheKeys = [`find:${this.table}`, `findOne:${this.table}:${id}`];
|
|
637
|
-
await this.cache.invalidate(cacheKeys);
|
|
638
|
-
if (this.cache.getStrategy() === "write-through") {
|
|
639
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
this.logger.logDebug(
|
|
644
|
-
`Updated ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
645
|
-
);
|
|
646
|
-
return result;
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
/**
|
|
650
|
-
* Updates multiple records based on different conditions.
|
|
651
|
-
* @param updates An array of update operations, each with a `where` and `set` clause.
|
|
652
|
-
* @param options Optional: Specify batch size.
|
|
653
|
-
* @returns A promise that resolves when the operation is complete.
|
|
654
|
-
* @example
|
|
655
|
-
* ```
|
|
656
|
-
* await userRepository.bulkUpdate([
|
|
657
|
-
* { where: { condition: 'id = ?', params: [1] }, set: { status: 'inactive' } },
|
|
658
|
-
* { where: { condition: 'id = ?', params: [2] }, set: { status: 'inactive' } }
|
|
659
|
-
* ]);
|
|
660
|
-
* ```
|
|
661
|
-
*/
|
|
662
|
-
async bulkUpdate(
|
|
663
|
-
updates: { where: { condition: string; params: any[] }; set: Partial<T> }[],
|
|
664
|
-
options: { batchSize?: number } = {},
|
|
665
|
-
): Promise<void> {
|
|
666
|
-
return this.client.transaction((txClient) =>
|
|
667
|
-
this._bulkUpdate(updates, options, txClient),
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* @internal
|
|
673
|
-
* The private implementation for bulk updating records, executed within a transaction.
|
|
674
|
-
*/
|
|
675
|
-
private async _bulkUpdate(
|
|
676
|
-
updates: { where: { condition: string; params: any[] }; set: Partial<T> }[],
|
|
677
|
-
options: { batchSize?: number },
|
|
678
|
-
client: DBClient,
|
|
679
|
-
): Promise<void> {
|
|
680
|
-
const start = performance.now();
|
|
681
|
-
this.logger.logDebug(
|
|
682
|
-
`Bulk updating ${updates.length} ${this.table} entities`,
|
|
683
|
-
);
|
|
684
|
-
if (!updates.length) return;
|
|
685
|
-
|
|
686
|
-
const batchSize = options.batchSize || 1000;
|
|
687
|
-
updates.forEach((update) => this.validate(update.set));
|
|
688
|
-
|
|
689
|
-
const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
|
|
690
|
-
|
|
691
|
-
for (let i = 0; i < updates.length; i += batchSize) {
|
|
692
|
-
const batch = updates.slice(i, i + batchSize);
|
|
693
|
-
for (const update of batch) {
|
|
694
|
-
const rows = await client.query<{ id: number | string }>(
|
|
695
|
-
`SELECT id FROM ${this.table} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`,
|
|
696
|
-
update.where.params,
|
|
697
|
-
);
|
|
698
|
-
for (const { id } of rows) {
|
|
699
|
-
const before = await this.findOne(id, {}, client);
|
|
700
|
-
if (!before) continue;
|
|
701
|
-
|
|
702
|
-
const instance = new ((this as any).model || Object)();
|
|
703
|
-
Object.assign(instance, before, update.set);
|
|
704
|
-
|
|
705
|
-
await this.runHooks(instance, "beforeUpdate");
|
|
706
|
-
await this.runHooks(instance, "beforeSave");
|
|
707
|
-
|
|
708
|
-
const updateWithTimestamps = {
|
|
709
|
-
...update.set,
|
|
710
|
-
...(timestamps.updatedAt && !(update.set as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
|
|
711
|
-
} as Partial<T>;
|
|
712
|
-
|
|
713
|
-
const keys = Object.keys(updateWithTimestamps).filter((k) => this.columns[k]);
|
|
714
|
-
const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
|
|
715
|
-
const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
|
|
716
|
-
const params = [
|
|
717
|
-
...keys.map((k) => (updateWithTimestamps as any)[k]),
|
|
718
|
-
id,
|
|
719
|
-
];
|
|
720
|
-
await client.query(query, params);
|
|
721
|
-
|
|
722
|
-
const after = await this.findOne(id, {}, client);
|
|
723
|
-
if (after) {
|
|
724
|
-
await this.runHooks(after, "afterUpdate");
|
|
725
|
-
await this.runHooks(after, "afterSave");
|
|
726
|
-
if (this.versioned) {
|
|
727
|
-
await this.writeHistory(
|
|
728
|
-
{
|
|
729
|
-
...after,
|
|
730
|
-
version: (before as any).version ? (before as any).version + 1 : 1
|
|
731
|
-
},
|
|
732
|
-
"update",
|
|
733
|
-
client
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
742
|
-
|
|
743
|
-
this.logger.logDebug(
|
|
744
|
-
`Bulk updated ${updates.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
745
|
-
);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/**
|
|
749
|
-
* Performs an "update or insert" operation based on a set of unique keys.
|
|
750
|
-
* @param entity The entity to upsert.
|
|
751
|
-
* @param keys The list of key names that uniquely identify a record.
|
|
752
|
-
* @returns A promise that resolves to the upserted entity.
|
|
753
|
-
* @example
|
|
754
|
-
* ```
|
|
755
|
-
* const user = await userRepository.upsert({ email: 'lwazicd@icloud.com', name: 'Lwazi' }, ['email']);
|
|
756
|
-
* ```
|
|
757
|
-
*/
|
|
758
|
-
async upsert(entity: Partial<T>, keys: string[]): Promise<T> {
|
|
759
|
-
return this.client.transaction((txClient) =>
|
|
760
|
-
this._upsert(entity, keys, txClient),
|
|
761
|
-
);
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* @internal
|
|
766
|
-
* The private implementation for an upsert operation, executed within a transaction.
|
|
767
|
-
*/
|
|
768
|
-
private async _upsert(
|
|
769
|
-
entity: Partial<T>,
|
|
770
|
-
keys: string[],
|
|
771
|
-
client: DBClient,
|
|
772
|
-
): Promise<T> {
|
|
773
|
-
const start = performance.now();
|
|
774
|
-
this.logger.logDebug(
|
|
775
|
-
`Upserting ${this.table} with keys: ${keys.join(", ")}`,
|
|
776
|
-
);
|
|
777
|
-
this.validate(entity);
|
|
778
|
-
|
|
779
|
-
const dbType = this.getDBType(client);
|
|
780
|
-
const columns = Object.keys(entity).filter((k) => this.columns[k]);
|
|
781
|
-
const columnNames = columns.map((k) => this.columns[k]?.name).join(", ");
|
|
782
|
-
const placeholders = columns.map(() => "?").join(", ");
|
|
783
|
-
|
|
784
|
-
const updateClause = columns
|
|
785
|
-
.filter((c) => !keys.includes(c))
|
|
786
|
-
.map((c) => `${this.columns[c]?.name} = ?`).join(", ");
|
|
787
|
-
|
|
788
|
-
let query: string;
|
|
789
|
-
const updateParams = columns.filter((c) => !keys.includes(c)).map((k) => (entity as any)[k]);
|
|
790
|
-
const insertParams = columns.map((k) => (entity as any)[k]);
|
|
791
|
-
let params = [...insertParams, ...updateParams];
|
|
792
|
-
|
|
793
|
-
let before: T | null = null;
|
|
794
|
-
let isUpdate = false;
|
|
795
|
-
if (this.versioned && keys.length > 0) {
|
|
796
|
-
const whereClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(" AND ");
|
|
797
|
-
const whereParams = keys.map((k) => (entity as any)[k]);
|
|
798
|
-
const found = await client.query<T>(
|
|
799
|
-
`SELECT * FROM ${this.table} WHERE ${whereClause} LIMIT 1`,
|
|
800
|
-
whereParams
|
|
801
|
-
);
|
|
802
|
-
before = found[0] || null;
|
|
803
|
-
isUpdate = !!before;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const instance = new ((this as any).model || Object)();
|
|
807
|
-
Object.assign(instance, before || {}, entity);
|
|
808
|
-
|
|
809
|
-
if (isUpdate) {
|
|
810
|
-
await this.runHooks(instance, "beforeUpdate");
|
|
811
|
-
await this.runHooks(instance, "beforeSave");
|
|
812
|
-
} else {
|
|
813
|
-
await this.runHooks(instance, "beforeCreate");
|
|
814
|
-
await this.runHooks(instance, "beforeSave");
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
if (dbType === DBType.SQLite) {
|
|
818
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
|
|
819
|
-
} else if (dbType === DBType.MySQL) {
|
|
820
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`;
|
|
821
|
-
} else {
|
|
822
|
-
const pgUpdateClause = columns
|
|
823
|
-
.filter((c) => !keys.includes(c))
|
|
824
|
-
.map((c) => `${this.columns[c]?.name} = EXCLUDED.${this.columns[c]?.name}`).join(", ");
|
|
825
|
-
query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT (${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${pgUpdateClause} RETURNING *`;
|
|
826
|
-
params = insertParams;
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
const results = await client.query<T>(query, params);
|
|
830
|
-
let id: number | string | undefined = (results[0] as any)?.id || (entity as any).id;
|
|
831
|
-
|
|
832
|
-
if (!id && dbType !== DBType.Postgres) {
|
|
833
|
-
if (dbType === DBType.SQLite) {
|
|
834
|
-
id = (await client.query<{ id: number }>("SELECT last_insert_rowid() as id"))[0]?.id;
|
|
835
|
-
} else if (dbType === DBType.MySQL) {
|
|
836
|
-
const result = await client.query<{ "LAST_INSERT_ID()": number }>("SELECT LAST_INSERT_ID()");
|
|
837
|
-
id = result[0]?.["LAST_INSERT_ID()"];
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
if (!id) throw new StabilizeError("Failed to retrieve upserted ID", "UPSERT_ERROR");
|
|
842
|
-
|
|
843
|
-
const result = results[0] ?? ((await this.findOne(id, {}, client)) as T);
|
|
844
|
-
|
|
845
|
-
if (isUpdate) {
|
|
846
|
-
await this.runHooks(result, "afterUpdate");
|
|
847
|
-
await this.runHooks(result, "afterSave");
|
|
848
|
-
} else {
|
|
849
|
-
await this.runHooks(result, "afterCreate");
|
|
850
|
-
await this.runHooks(result, "afterSave");
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
if (this.versioned) {
|
|
854
|
-
await this.writeHistory(
|
|
855
|
-
{ ...result, version: before ? ((before as any).version ? (before as any).version + 1 : 1) : 1 },
|
|
856
|
-
before ? "update" : "insert",
|
|
857
|
-
client
|
|
858
|
-
);
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
if (this.cache) {
|
|
862
|
-
await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
863
|
-
if (this.cache.getStrategy() === "write-through") {
|
|
864
|
-
await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
this.logger.logDebug(
|
|
869
|
-
`Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
870
|
-
);
|
|
871
|
-
return result;
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
/**
|
|
875
|
-
* Deletes a record by its ID. Performs a soft delete if enabled on the model.
|
|
876
|
-
* @param id The ID of the record to delete.
|
|
877
|
-
* @returns A promise that resolves when the operation is complete.
|
|
878
|
-
* @example
|
|
879
|
-
* ```
|
|
880
|
-
* await userRepository.delete(1);
|
|
881
|
-
* ```
|
|
882
|
-
*/
|
|
883
|
-
async delete(id: number | string): Promise<void> {
|
|
884
|
-
return this.client.transaction(async (txClient) => {
|
|
885
|
-
const before = await this.findOne(id, {}, txClient);
|
|
886
|
-
if (!before) throw new StabilizeError("Not found", "DELETE_ERROR");
|
|
887
|
-
await this.runHooks(before, "beforeDelete");
|
|
888
|
-
|
|
889
|
-
await this._delete(id, txClient);
|
|
890
|
-
|
|
891
|
-
await this.runHooks(before, "afterDelete");
|
|
892
|
-
await this.writeHistory(before, "delete", txClient);
|
|
893
|
-
});
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* @internal
|
|
898
|
-
* The private implementation for deleting a record, executed within a transaction.
|
|
899
|
-
*/
|
|
900
|
-
private async _delete(id: number | string, client: DBClient): Promise<void> {
|
|
901
|
-
const start = performance.now();
|
|
902
|
-
this.logger.logDebug(`Deleting ${this.table} with ID ${id}`);
|
|
903
|
-
|
|
904
|
-
const query = this.softDeleteField
|
|
905
|
-
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
906
|
-
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
907
|
-
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
908
|
-
|
|
909
|
-
await client.query(query, params);
|
|
910
|
-
|
|
911
|
-
if (this.cache) {
|
|
912
|
-
await this.cache.invalidate([
|
|
913
|
-
`find:${this.table}`,
|
|
914
|
-
`findOne:${this.table}:${id}`,
|
|
915
|
-
]);
|
|
916
|
-
}
|
|
917
|
-
this.logger.logDebug(
|
|
918
|
-
`Deleted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
919
|
-
);
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* Deletes multiple records by their IDs in batches.
|
|
924
|
-
* @param ids An array of IDs to delete.
|
|
925
|
-
* @param options Optional: Specify batch size.
|
|
926
|
-
* @returns A promise that resolves when the operation is complete.
|
|
927
|
-
* @example
|
|
928
|
-
* ```
|
|
929
|
-
* await userRepository.bulkDelete([1, 2, 3]);
|
|
930
|
-
* ```
|
|
931
|
-
*/
|
|
932
|
-
async bulkDelete(
|
|
933
|
-
ids: (number | string)[],
|
|
934
|
-
options: { batchSize?: number } = {},
|
|
935
|
-
): Promise<void> {
|
|
936
|
-
return this.client.transaction((txClient) =>
|
|
937
|
-
this._bulkDelete(ids, options, txClient),
|
|
938
|
-
);
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
/**
|
|
942
|
-
* @internal
|
|
943
|
-
* The private implementation for bulk deleting records, executed within a transaction.
|
|
944
|
-
*/
|
|
945
|
-
private async _bulkDelete(
|
|
946
|
-
ids: (number | string)[],
|
|
947
|
-
options: { batchSize?: number },
|
|
948
|
-
client: DBClient,
|
|
949
|
-
): Promise<void> {
|
|
950
|
-
const start = performance.now();
|
|
951
|
-
this.logger.logDebug(`Bulk deleting ${ids.length} ${this.table} entities`);
|
|
952
|
-
if (!ids.length) return;
|
|
953
|
-
|
|
954
|
-
const batchSize = options.batchSize || 1000;
|
|
955
|
-
for (let i = 0; i < ids.length; i += batchSize) {
|
|
956
|
-
const batch = ids.slice(i, i + batchSize);
|
|
957
|
-
for (const id of batch) {
|
|
958
|
-
const before = await this.findOne(id, {}, client);
|
|
959
|
-
if (!before) continue;
|
|
960
|
-
|
|
961
|
-
await this.runHooks(before, "beforeDelete");
|
|
962
|
-
|
|
963
|
-
const query = this.softDeleteField
|
|
964
|
-
? `UPDATE ${this.table} SET ${this.softDeleteField} = ? WHERE id = ?`
|
|
965
|
-
: `DELETE FROM ${this.table} WHERE id = ?`;
|
|
966
|
-
const params = this.softDeleteField ? [new Date().toISOString(), id] : [id];
|
|
967
|
-
|
|
968
|
-
await client.query(query, params);
|
|
969
|
-
|
|
970
|
-
await this.runHooks(before, "afterDelete");
|
|
971
|
-
|
|
972
|
-
if (this.versioned) {
|
|
973
|
-
await this.writeHistory(before, "delete", client);
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
if (this.cache) await this.cache.invalidatePattern(`find:${this.table}:*`);
|
|
979
|
-
|
|
980
|
-
this.logger.logDebug(
|
|
981
|
-
`Bulk deleted ${ids.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
|
|
982
|
-
);
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* Recovers a soft-deleted record by its ID.
|
|
987
|
-
* Throws an error if soft delete is not enabled on the model.
|
|
988
|
-
* @param id The ID of the record to recover.
|
|
989
|
-
* @returns A promise that resolves to the recovered entity.
|
|
990
|
-
* @example
|
|
991
|
-
* ```
|
|
992
|
-
* const recoveredUser = await userRepository.recover(1);
|
|
993
|
-
* ```
|
|
994
|
-
*/
|
|
995
|
-
async recover(id: number | string): Promise<T> {
|
|
996
|
-
return this.client.transaction((txClient) => this._recover(id, txClient));
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* @internal
|
|
1001
|
-
* The private implementation for recovering a record, executed within a transaction.
|
|
1002
|
-
*/
|
|
1003
|
-
private async _recover(id: number | string, client: DBClient): Promise<T> {
|
|
1004
|
-
const start = performance.now();
|
|
1005
|
-
this.logger.logDebug(`Recovering ${this.table} with ID ${id}`);
|
|
1006
|
-
if (!this.softDeleteField) {
|
|
1007
|
-
throw new StabilizeError(
|
|
1008
|
-
"Soft delete not enabled for this model",
|
|
1009
|
-
"RECOVER_ERROR",
|
|
1010
|
-
);
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
await client.query(
|
|
1014
|
-
`UPDATE ${this.table} SET ${this.softDeleteField} = NULL WHERE id = ?`,
|
|
1015
|
-
[id],
|
|
1016
|
-
);
|
|
1017
|
-
|
|
1018
|
-
const result = await this.findOne(id, {}, client);
|
|
1019
|
-
if (!result) throw new StabilizeError("Failed to find recovered record.", "RECOVER_ERROR");
|
|
1020
|
-
|
|
1021
|
-
if (this.cache) {
|
|
1022
|
-
await this.cache.invalidate([
|
|
1023
|
-
`find:${this.table}`,
|
|
1024
|
-
`findOne:${this.table}:${id}`,
|
|
1025
|
-
]);
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
this.logger.logDebug(
|
|
1029
|
-
`Recovered ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
|
|
1030
|
-
);
|
|
1031
|
-
return result;
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
/**
|
|
1035
|
-
* Executes a raw SQL query directly against the database.
|
|
1036
|
-
* Bypasses most ORM abstractions. Use with caution.
|
|
1037
|
-
* @param query The raw SQL query string with `?` placeholders.
|
|
1038
|
-
* @param params An array of parameters to bind to the query.
|
|
1039
|
-
* @returns A promise that resolves to an array of results.
|
|
1040
|
-
* @example
|
|
1041
|
-
* ```
|
|
1042
|
-
* const activeUsers = await userRepository.rawQuery('SELECT * FROM users WHERE status = ?', ['active']);
|
|
1043
|
-
* ```
|
|
1044
|
-
*/
|
|
1045
|
-
async rawQuery<T>(query: string, params: any[] = []): Promise<T[]> {
|
|
1046
|
-
const start = performance.now();
|
|
1047
|
-
this.logger.logDebug(`Executing raw query: ${query}`);
|
|
1048
|
-
const result = await this.client.query<T>(query, params);
|
|
1049
|
-
this.logger.logDebug(
|
|
1050
|
-
`Raw query completed in ${(performance.now() - start).toFixed(2)}ms`,
|
|
1051
|
-
);
|
|
1052
|
-
return result;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* @internal
|
|
1057
|
-
* Loads a relation by adding the appropriate JOIN clause to a query builder.
|
|
1058
|
-
* @param queryBuilder The `QueryBuilder` instance to modify.
|
|
1059
|
-
* @param relation The name of the relation to load.
|
|
1060
|
-
*/
|
|
1061
|
-
private async loadRelation(queryBuilder: QueryBuilder<T>, relation: string) {
|
|
1062
|
-
this.logger.logDebug(`Loading relation ${relation} for ${this.table}`);
|
|
1063
|
-
const rel = this.relations[relation];
|
|
1064
|
-
if (!rel)
|
|
1065
|
-
throw new StabilizeError(
|
|
1066
|
-
`Relation ${relation} not found`,
|
|
1067
|
-
"RELATION_ERROR",
|
|
1068
|
-
);
|
|
1069
|
-
|
|
1070
|
-
const relatedTable = MetadataStorage.getTableName(rel.targetModel());
|
|
1071
|
-
if (
|
|
1072
|
-
rel.type === RelationType.OneToOne ||
|
|
1073
|
-
rel.type === RelationType.ManyToOne
|
|
1074
|
-
) {
|
|
1075
|
-
queryBuilder.join(
|
|
1076
|
-
relatedTable,
|
|
1077
|
-
`${this.table}.${rel.foreignKey} = ${relatedTable}.id`,
|
|
1078
|
-
);
|
|
1079
|
-
} else if (rel.type === RelationType.OneToMany) {
|
|
1080
|
-
queryBuilder.join(
|
|
1081
|
-
relatedTable,
|
|
1082
|
-
`${relatedTable}.${rel.inverseKey} = ${this.table}.id`,
|
|
1083
|
-
);
|
|
1084
|
-
} else if (rel.type === RelationType.ManyToMany) {
|
|
1085
|
-
queryBuilder
|
|
1086
|
-
.join(
|
|
1087
|
-
rel.joinTable!,
|
|
1088
|
-
`${rel.joinTable}.${rel.foreignKey} = ${this.table}.id`,
|
|
1089
|
-
)
|
|
1090
|
-
.join(
|
|
1091
|
-
relatedTable,
|
|
1092
|
-
`${relatedTable}.id = ${rel.joinTable}.${rel.inverseKey}`,
|
|
1093
|
-
);
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
}
|