stabilize-orm 1.2.0 → 1.3.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/repository.ts CHANGED
@@ -9,24 +9,17 @@ import { DBClient } from "./client";
9
9
  import { ConsoleLogger, type Logger } from "./logger";
10
10
  import { QueryBuilder } from "./query-builder";
11
11
  import {
12
+ DataTypes,
12
13
  DBType,
13
14
  RelationType,
14
15
  StabilizeError,
15
16
  type CacheConfig,
16
17
  } from "./types";
17
- import {
18
- ModelKey,
19
- ColumnKey,
20
- ValidatorKey,
21
- RelationKey,
22
- SoftDeleteKey,
23
- VersionedKey,
24
- } from "./decorators";
18
+ import { MetadataStorage } from "./model";
25
19
  import { getHooks, type HookType } from "./hooks";
26
20
 
27
21
  type VersionOperation = "insert" | "update" | "delete";
28
22
 
29
-
30
23
  /**
31
24
  * Provides a generic repository for a model `T`.
32
25
  * This class abstracts the database interactions for a specific model,
@@ -57,7 +50,7 @@ export class Repository<T> {
57
50
  /**
58
51
  * Creates an instance of Repository.
59
52
  * @param client The database client instance for executing queries.
60
- * @param model The model class constructor, decorated with `@Model`.
53
+ * @param model The model class constructor, defined with `defineModel`.
61
54
  * @param cacheConfig Optional configuration for caching.
62
55
  * @param logger A logger instance for logging messages.
63
56
  */
@@ -69,14 +62,30 @@ export class Repository<T> {
69
62
  ) {
70
63
  this.client = client;
71
64
  this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
72
- this.table = Reflect.getMetadata(ModelKey, model) || "";
73
- this.columns = Reflect.getMetadata(ColumnKey, model.prototype) || {};
74
- this.validators = Reflect.getMetadata(ValidatorKey, model.prototype) || {};
75
- this.relations = Reflect.getMetadata(RelationKey, model.prototype) || {};
76
- this.softDeleteField =
77
- Reflect.getMetadata(SoftDeleteKey, model.prototype) || null;
65
+ this.table = MetadataStorage.getTableName(model);
66
+ this.columns = Object.fromEntries(
67
+ Object.entries(MetadataStorage.getColumns(model)).map(([key, col]) => [
68
+ key,
69
+ { name: col.name ?? key, type: typeof col.type === 'string' ? col.type : DataTypes[col.type] },
70
+ ])
71
+ );
72
+ this.relations = Object.fromEntries(
73
+ Object.entries(MetadataStorage.getRelations(model)).map(([key, rel]) => [
74
+ key,
75
+ {
76
+ type: rel.type,
77
+ targetModel: rel.target,
78
+ foreignKey: rel.foreignKey,
79
+ inverseKey: rel.inverseKey,
80
+ joinTable: rel.joinTable,
81
+ },
82
+ ])
83
+ );
84
+ this.validators = MetadataStorage.getValidators(model);
85
+
86
+ this.softDeleteField = MetadataStorage.getSoftDeleteField(model);
78
87
  this.logger = logger;
79
- this.versioned = !!Reflect.getMetadata(VersionedKey, model);
88
+ this.versioned = MetadataStorage.isVersioned(model);
80
89
  this.historyTable = `${this.table}_history`;
81
90
  }
82
91
 
@@ -93,7 +102,7 @@ export class Repository<T> {
93
102
 
94
103
  /**
95
104
  * @internal
96
- * Validates an entity against the 'required' constraints defined in decorators.
105
+ * Validates an entity against the 'required' constraints defined in the model configuration.
97
106
  * @param entity The partial entity to validate.
98
107
  */
99
108
  private validate(entity: Partial<T>) {
@@ -112,13 +121,13 @@ export class Repository<T> {
112
121
  }
113
122
 
114
123
  /**
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
- */
124
+ * Runs lifecycle hooks of a given type for the entity.
125
+ * @param entity The entity instance.
126
+ * @param type The hook type (e.g., 'beforeCreate').
127
+ */
119
128
  private async runHooks(entity: any, type: HookType): Promise<void> {
120
129
  for (const hook of getHooks(entity, type)) {
121
- await hook();
130
+ await hook.callback(entity);
122
131
  }
123
132
  }
124
133
 
@@ -173,8 +182,8 @@ export class Repository<T> {
173
182
  }
174
183
 
175
184
  /**
176
- * Snapshot query: get record as it was at a point in time.
177
- */
185
+ * Snapshot query: get record as it was at a point in time.
186
+ */
178
187
  async asOf(
179
188
  id: number | string,
180
189
  asOfDate: Date,
@@ -190,8 +199,8 @@ export class Repository<T> {
190
199
  }
191
200
 
192
201
  /**
193
- * Get all history for a record.
194
- */
202
+ * Get all history for a record.
203
+ */
195
204
  async history(
196
205
  id: number | string,
197
206
  _client?: DBClient
@@ -204,8 +213,6 @@ export class Repository<T> {
204
213
  );
205
214
  }
206
215
 
207
-
208
-
209
216
  /**
210
217
  * Rollback a record to a previous version.
211
218
  */
@@ -237,21 +244,12 @@ export class Repository<T> {
237
244
  });
238
245
  }
239
246
 
240
-
241
247
  /**
242
248
  * 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")
249
+ * @param entity The entity object being versioned
250
+ * @param operation The operation performed ("insert", "update", "delete")
251
+ * @param client The database client to use for the insert
252
+ * @param user The user/system responsible for the change (default: "system")
255
253
  */
256
254
  private async writeHistory(
257
255
  entity: any,
@@ -261,11 +259,9 @@ export class Repository<T> {
261
259
  ) {
262
260
  if (!this.versioned) return;
263
261
 
264
- // Get property keys and corresponding SQL column names
265
262
  const propertyKeys = Object.keys(this.columns);
266
263
  const sqlColumnNames = propertyKeys.map((k) => this.columns[k]!.name);
267
264
 
268
- // Build historyColumns using SQL column names
269
265
  const historyColumns = [
270
266
  ...sqlColumnNames,
271
267
  "operation",
@@ -276,9 +272,7 @@ export class Repository<T> {
276
272
  "modified_at"
277
273
  ];
278
274
 
279
- // Map values from entity using property keys
280
275
  const values = propertyKeys.map((k) => entity[k]);
281
-
282
276
  const params = [
283
277
  ...values,
284
278
  operation,
@@ -288,7 +282,7 @@ export class Repository<T> {
288
282
  user || "system",
289
283
  new Date()
290
284
  ];
291
- // Database-agnostic placeholder formatting
285
+
292
286
  let placeholders: string;
293
287
  if (client.config.type === DBType.Postgres) {
294
288
  placeholders = params.map((_, i) => `$${i + 1}`).join(", ");
@@ -332,6 +326,7 @@ export class Repository<T> {
332
326
  return result;
333
327
  });
334
328
  }
329
+
335
330
  /**
336
331
  * @internal
337
332
  * The private implementation for creating a record, executed within a transaction.
@@ -407,7 +402,6 @@ export class Repository<T> {
407
402
  options: { relations?: string[]; batchSize?: number } = {},
408
403
  ): Promise<T[]> {
409
404
  return this.client.transaction(async (txClient) => {
410
- // Prepare entity instances for hooks
411
405
  const preparedEntities = entities.map(data => {
412
406
  const instance = new (this as any).model();
413
407
  Object.assign(instance, data);
@@ -464,7 +458,6 @@ export class Repository<T> {
464
458
  );
465
459
 
466
460
  if (dbType === DBType.Postgres) {
467
- // PostgreSQL: numbered placeholders ($1, $2, ...)
468
461
  let paramIdx = 1;
469
462
  const valuePlaceholders = batch
470
463
  .map(
@@ -476,7 +469,6 @@ export class Repository<T> {
476
469
  const batchResults = await client.query<T>(query, params);
477
470
  const ids = batchResults.map((r) => (r as any).id);
478
471
 
479
- // Handle relation loading if needed
480
472
  let finalResults = batchResults;
481
473
  if (ids.length > 0 && batchResults.length === 0) {
482
474
  const queryBuilder = this.find().where(
@@ -493,7 +485,6 @@ export class Repository<T> {
493
485
  results.push(...finalResults);
494
486
 
495
487
  } else {
496
- // SQLite/MySQL: ? placeholders
497
488
  const placeholders = `(${keys.map(() => "?").join(", ")})`;
498
489
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
499
490
  await client.query(query, params);
@@ -642,17 +633,14 @@ export class Repository<T> {
642
633
  for (let i = 0; i < updates.length; i += batchSize) {
643
634
  const batch = updates.slice(i, i + batchSize);
644
635
  for (const update of batch) {
645
- // Find all IDs matching the where clause
646
636
  const rows = await client.query<{ id: number | string }>(
647
637
  `SELECT id FROM ${this.table} WHERE ${update.where.condition}${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`,
648
638
  update.where.params,
649
639
  );
650
640
  for (const { id } of rows) {
651
- // Fetch record before update for versioning
652
641
  const before = await this.findOne(id, {}, client);
653
642
  if (!before) continue;
654
643
 
655
- // Prepare instance for hooks
656
644
  const instance = new ((this as any).model || Object)();
657
645
  Object.assign(instance, before, update.set);
658
646
 
@@ -693,6 +681,7 @@ export class Repository<T> {
693
681
  `Bulk updated ${updates.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
694
682
  );
695
683
  }
684
+
696
685
  /**
697
686
  * Performs an "update or insert" operation based on a set of unique keys.
698
687
  * @param entity The entity to upsert.
@@ -728,6 +717,7 @@ export class Repository<T> {
728
717
  const columns = Object.keys(entity).filter((k) => this.columns[k]);
729
718
  const columnNames = columns.map((k) => this.columns[k]?.name).join(", ");
730
719
  const placeholders = columns.map(() => "?").join(", ");
720
+
731
721
  const updateClause = columns
732
722
  .filter((c) => !keys.includes(c))
733
723
  .map((c) => `${this.columns[c]?.name} = ?`).join(", ");
@@ -737,7 +727,6 @@ export class Repository<T> {
737
727
  const insertParams = columns.map((k) => (entity as any)[k]);
738
728
  let params = [...insertParams, ...updateParams];
739
729
 
740
- // Try to find the record before upsert
741
730
  let before: T | null = null;
742
731
  let isUpdate = false;
743
732
  if (this.versioned && keys.length > 0) {
@@ -751,7 +740,6 @@ export class Repository<T> {
751
740
  isUpdate = !!before;
752
741
  }
753
742
 
754
- // Prepare instance for hooks
755
743
  const instance = new ((this as any).model || Object)();
756
744
  Object.assign(instance, before || {}, entity);
757
745
 
@@ -767,7 +755,7 @@ export class Repository<T> {
767
755
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON CONFLICT(${keys.map((k) => this.columns[k]!.name).join(", ")}) DO UPDATE SET ${updateClause}`;
768
756
  } else if (dbType === DBType.MySQL) {
769
757
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders}) ON DUPLICATE KEY UPDATE ${updateClause}`;
770
- } else { // PostgreSQL
758
+ } else {
771
759
  const pgUpdateClause = columns
772
760
  .filter((c) => !keys.includes(c))
773
761
  .map((c) => `${this.columns[c]?.name} = EXCLUDED.${this.columns[c]?.name}`).join(", ");
@@ -799,13 +787,6 @@ export class Repository<T> {
799
787
  await this.runHooks(result, "afterSave");
800
788
  }
801
789
 
802
- if (this.cache) {
803
- await this.cache.invalidatePattern(`find:${this.table}:*`);
804
- if (this.cache.getStrategy() === "write-through") {
805
- await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
806
- }
807
- }
808
-
809
790
  if (this.versioned) {
810
791
  await this.writeHistory(
811
792
  { ...result, version: before ? ((before as any).version ? (before as any).version + 1 : 1) : 1 },
@@ -814,11 +795,19 @@ export class Repository<T> {
814
795
  );
815
796
  }
816
797
 
798
+ if (this.cache) {
799
+ await this.cache.invalidatePattern(`find:${this.table}:*`);
800
+ if (this.cache.getStrategy() === "write-through") {
801
+ await this.cache.set(`findOne:${this.table}:${id}`, [result], 60);
802
+ }
803
+ }
804
+
817
805
  this.logger.logDebug(
818
806
  `Upserted ${this.table} with ID ${id} in ${(performance.now() - start).toFixed(2)}ms`,
819
807
  );
820
808
  return result;
821
809
  }
810
+
822
811
  /**
823
812
  * Deletes a record by its ID. Performs a soft delete if enabled on the model.
824
813
  * @param id The ID of the record to delete.
@@ -929,6 +918,7 @@ export class Repository<T> {
929
918
  `Bulk deleted ${ids.length} ${this.table} entities in ${(performance.now() - start).toFixed(2)}ms`,
930
919
  );
931
920
  }
921
+
932
922
  /**
933
923
  * Recovers a soft-deleted record by its ID.
934
924
  * Throws an error if soft delete is not enabled on the model.
@@ -1014,7 +1004,7 @@ export class Repository<T> {
1014
1004
  "RELATION_ERROR",
1015
1005
  );
1016
1006
 
1017
- const relatedTable = Reflect.getMetadata(ModelKey, rel.targetModel());
1007
+ const relatedTable = MetadataStorage.getTableName(rel.targetModel());
1018
1008
  if (
1019
1009
  rel.type === RelationType.OneToOne ||
1020
1010
  rel.type === RelationType.ManyToOne
@@ -1040,4 +1030,4 @@ export class Repository<T> {
1040
1030
  );
1041
1031
  }
1042
1032
  }
1043
- }
1033
+ }
package/decorators.ts DELETED
@@ -1,170 +0,0 @@
1
- /**
2
- * @file decorators.ts
3
- * @description Contains all the decorators used by the Stabilize ORM.
4
- * @author ElectronSz
5
- */
6
-
7
- import "reflect-metadata";
8
- import { RelationType, DataTypes } from "./types";
9
-
10
- export const ModelKey = Symbol("model");
11
- export const ColumnKey = Symbol("column");
12
- export const ValidatorKey = Symbol("validator");
13
- export const RelationKey = Symbol("relation");
14
- export const SoftDeleteKey = Symbol("softDelete");
15
- export const DefaultKey = Symbol("default");
16
- export const IndexKey = Symbol("index");
17
- export const VersionedKey = Symbol("versioned");
18
-
19
- export interface ColumnOptions {
20
- name?: string;
21
- type: DataTypes;
22
- length?: number;
23
- precision?: number;
24
- scale?: number;
25
- }
26
-
27
- /**
28
- * Decorator to mark a class as a database model.
29
- * @param tableName The name of the table in the database.
30
- */
31
- export function Model(tableName: string) {
32
- return function (constructor: Function) {
33
- Reflect.defineMetadata(ModelKey, tableName, constructor);
34
- };
35
- }
36
-
37
- /**
38
- * Decorator to mark a property as a database column.
39
- * @param options The configuration for the column, including name, type, and length.
40
- */
41
- export function Column(options: ColumnOptions | DataTypes) {
42
- return function (target: any, propertyKey: string) {
43
- const columns = Reflect.getMetadata(ColumnKey, target) || {};
44
- const columnOptions: ColumnOptions = typeof options === 'object' ? options : { type: options };
45
- columns[propertyKey] = {
46
- name: columnOptions.name || propertyKey,
47
- ...columnOptions,
48
- };
49
- Reflect.defineMetadata(ColumnKey, columns, target);
50
- };
51
- }
52
-
53
- /**
54
- * Decorator to enforce a NOT NULL constraint on a column.
55
- */
56
- export function Required() {
57
- return function (target: any, propertyKey: string) {
58
- const validators = Reflect.getMetadata(ValidatorKey, target) || {};
59
- validators[propertyKey] = [...(validators[propertyKey] || []), "required"];
60
- Reflect.defineMetadata(ValidatorKey, validators, target);
61
- };
62
- }
63
-
64
- /**
65
- * Decorator to enforce a UNIQUE constraint on a column.
66
- */
67
- export function Unique() {
68
- return function (target: any, propertyKey: string) {
69
- const validators = Reflect.getMetadata(ValidatorKey, target) || {};
70
- validators[propertyKey] = [...(validators[propertyKey] || []), "unique"];
71
- Reflect.defineMetadata(ValidatorKey, validators, target);
72
- };
73
- }
74
-
75
- /**
76
- * Decorator to set a default value for a column.
77
- * @param value The default value.
78
- */
79
- export function Default(value: any) {
80
- return function (target: any, propertyKey: string) {
81
- Reflect.defineMetadata(DefaultKey, value, target, propertyKey);
82
- };
83
- }
84
-
85
- /**
86
- * Decorator to create a non-unique index on a column for performance.
87
- * @param indexName Optional: A custom name for the index.
88
- */
89
- export function Index(indexName?: string) {
90
- return function (target: any, propertyKey: string) {
91
- const indexes = Reflect.getMetadata(IndexKey, target) || {};
92
- indexes[propertyKey] = indexName || `idx_${propertyKey}`;
93
- Reflect.defineMetadata(IndexKey, indexes, target);
94
- };
95
- }
96
-
97
- /**
98
- * Decorator to enable soft-delete functionality on a model.
99
- * The decorated property will store the deletion timestamp.
100
- */
101
- export function SoftDelete() {
102
- return function (target: any, propertyKey: string) {
103
- Reflect.defineMetadata(SoftDeleteKey, propertyKey, target);
104
- };
105
- }
106
-
107
- /**
108
- * Decorator to enable versioning (history, snapshot & time-travel) on a model.
109
- */
110
- export function Versioned() {
111
- return function (target: any) {
112
- Reflect.defineMetadata(VersionedKey, true, target);
113
- };
114
- }
115
-
116
-
117
- export function OneToOne(model: () => any, foreignKey: string) {
118
- return function (target: any, propertyKey: string) {
119
- const relations = Reflect.getMetadata(RelationKey, target) || {};
120
- relations[propertyKey] = {
121
- type: RelationType.OneToOne,
122
- targetModel: model,
123
- foreignKey,
124
- };
125
- Reflect.defineMetadata(RelationKey, relations, target);
126
- };
127
- }
128
-
129
- export function ManyToOne(model: () => any, foreignKey: string) {
130
- return function (target: any, propertyKey: string) {
131
- const relations = Reflect.getMetadata(RelationKey, target) || {};
132
- relations[propertyKey] = {
133
- type: RelationType.ManyToOne,
134
- targetModel: model,
135
- foreignKey,
136
- };
137
- Reflect.defineMetadata(RelationKey, relations, target);
138
- };
139
- }
140
-
141
- export function OneToMany(model: () => any, inverseKey: string) {
142
- return function (target: any, propertyKey: string) {
143
- const relations = Reflect.getMetadata(RelationKey, target) || {};
144
- relations[propertyKey] = {
145
- type: RelationType.OneToMany,
146
- targetModel: model,
147
- inverseKey,
148
- };
149
- Reflect.defineMetadata(RelationKey, relations, target);
150
- };
151
- }
152
-
153
- export function ManyToMany(
154
- model: () => any,
155
- joinTable: string,
156
- foreignKey: string,
157
- inverseKey: string,
158
- ) {
159
- return function (target: any, propertyKey: string) {
160
- const relations = Reflect.getMetadata(RelationKey, target) || {};
161
- relations[propertyKey] = {
162
- type: RelationType.ManyToMany,
163
- targetModel: model,
164
- joinTable,
165
- foreignKey,
166
- inverseKey,
167
- };
168
- Reflect.defineMetadata(RelationKey, relations, target);
169
- };
170
- }