stabilize-orm 1.3.0 → 1.3.3

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/CHANGELOG.md CHANGED
@@ -6,6 +6,30 @@ All notable changes to this project will be documented in this file.
6
6
 
7
7
  - Further features and improvements coming soon.
8
8
 
9
+
10
+ ## [1.3.2] - 2025-10-19
11
+
12
+ ### Added
13
+ - Added **Timestamps Configuration** feature for automatic management of `createdAt` and `updatedAt` columns (`types.ts`, `model.ts`, `repository.ts`, `migrations.ts`).
14
+ - Added `TimestampsConfig` interface and `timestamps` property to `ModelConfig` in `types.ts`.
15
+ - Added `getTimestamps` method to `MetadataStorage` in `model.ts`.
16
+ - Updated `create`, `update`, `bulkCreate`, `bulkUpdate`, and `upsert` methods in `repository.ts` to set timestamps automatically.
17
+ - Updated `migrations.ts` to include timestamp columns in schema generation.
18
+ - Updated `README.md` with a new "Timestamps" section and example.
19
+
20
+ ### Fixed
21
+ - Fixed TypeScript error (TS7053) in `repository.ts` for timestamps handling in `_create`, `_bulkCreate`, `_bulkUpdate`, and `_upsert` methods by using `Record<string, any>` for safe property access and maintaining `Partial<T>` type safety.
22
+
23
+ ## [1.3.2] - 2025-10-19
24
+
25
+ ### Added
26
+ - Added **Custom Query Scopes** feature, allowing reusable query conditions defined in model configurations (`types.ts`, `model.ts`, `query-builder.ts`, `repository.ts`).
27
+ - Added `scopes` property to `ModelConfig` interface in `types.ts` to define scope functions.
28
+ - Added `getScopes` method to `MetadataStorage` in `model.ts` to retrieve scope definitions.
29
+ - Added `scope` method to `QueryBuilder` in `query-builder.ts` to apply scopes to queries.
30
+ - Added `scope` method to `Repository` in `repository.ts` for direct scope application.
31
+ - Updated `README.md` with a new "Custom Query Scopes" section and example.
32
+
9
33
  ## [1.3.0] - 2025-10-18
10
34
 
11
35
  ### Added
package/README.md CHANGED
@@ -22,9 +22,11 @@ _A Modern, Type-Safe, and Expressive ORM for Bun_
22
22
  - **Model Relationships**: Define `OneToOne`, `ManyToOne`, `OneToMany`, and `ManyToMany` relationships in the model configuration.
23
23
  - **Soft Deletes**: Enable soft deletes in the model configuration for transparent "deleted" flags and safe row removal.
24
24
  - **Lifecycle Hooks**: Define hooks in the model configuration or as class methods for lifecycle events like `beforeCreate`, `afterUpdate`, etc.
25
- - **Pluggable Logging**: Includes a robust `ConsoleLogger` with support for file-based, rotating logs.
25
+ - **Pluggable Logging**: Includes a robust `StabilizeLogger` with support for file-based, rotating logs.
26
26
  - **Custom Errors**: `StabilizeError` provides clear, consistent error handling.
27
27
  - **Caching Layer**: Optional Redis-backed caching with `cache-aside` and `write-through` strategies.
28
+ - **Custom Query Scopes**: Define reusable query conditions (scopes) in models for simplified, reusable filtering logic.
29
+ - **Timestamps**: Automatically manage `createdAt` and `updatedAt` columns for tracking record creation and update times.
28
30
 
29
31
  ---
30
32
 
@@ -358,11 +360,98 @@ console.log(activeAdmins);
358
360
  orderBy(clause: string): QueryBuilder<User>;
359
361
  limit(limit: number): QueryBuilder<User>;
360
362
  offset(offset: number): QueryBuilder<User>;
363
+ scope(name: string, ...args: any[]): QueryBuilder<User>;
361
364
  build(): { query: string; params: any[] };
362
365
  execute(client?: DBClient, cache?: Cache, cacheKey?: string): Promise<User[]>;
363
366
  }
364
367
  ```
365
368
 
369
+ ### Custom Query Scopes
370
+
371
+ Define reusable query conditions (scopes) in your model configuration to simplify and reuse common filtering logic. Scopes are applied via the `scope` method on `Repository` or `QueryBuilder`, allowing you to chain them with other query operations.
372
+
373
+ #### **Scopes Example**
374
+
375
+ ```typescript
376
+ import { defineModel, DataTypes } from "stabilize-orm";
377
+ import { orm } from "./db";
378
+
379
+ const User = defineModel({
380
+ tableName: "users",
381
+ columns: {
382
+ id: { type: DataTypes.Integer, required: true },
383
+ email: { type: DataTypes.String, length: 100, required: true },
384
+ isActive: { type: DataTypes.Boolean, required: true },
385
+ createdAt: { type: DataTypes.DateTime },
386
+ updatedAt: { type: DataTypes.DateTime },
387
+ },
388
+ scopes: {
389
+ active: (qb) => qb.where("isActive = ?", true),
390
+ recent: (qb, days: number) => qb.where("createdAt >= ?", new Date(Date.now() - days * 24 * 60 * 60 * 1000)),
391
+ },
392
+ });
393
+
394
+ const userRepository = orm.getRepository(User);
395
+
396
+ // Fetch active users
397
+ const activeUsers = await userRepository.scope("active").execute();
398
+
399
+ // Fetch users created in the last 7 days
400
+ const recentUsers = await userRepository.scope("recent", 7).execute();
401
+
402
+ // Combine scopes with other query operations
403
+ const recentActiveUsers = await userRepository
404
+ .scope("active")
405
+ .scope("recent", 7)
406
+ .orderBy("createdAt DESC")
407
+ .limit(10)
408
+ .execute();
409
+
410
+ console.log(recentActiveUsers);
411
+ ```
412
+
413
+ ### Timestamps
414
+
415
+ Enable automatic management of `createdAt` and `updatedAt` columns by setting `timestamps` in your model configuration. The ORM automatically sets these fields during `create`, `update`, `bulkCreate`, `bulkUpdate`, and `upsert` operations in a TypeScript-safe manner, eliminating the need for manual hooks.
416
+
417
+ #### **Timestamps Example**
418
+
419
+ ```typescript
420
+ import { defineModel, DataTypes } from "stabilize-orm";
421
+ import { orm } from "./db";
422
+
423
+ const User = defineModel({
424
+ tableName: "users",
425
+ columns: {
426
+ id: { type: DataTypes.Integer, required: true },
427
+ email: { type: DataTypes.String, length: 100, required: true },
428
+ createdAt: { type: DataTypes.DateTime },
429
+ updatedAt: { type: DataTypes.DateTime },
430
+ },
431
+ timestamps: {
432
+ createdAt: "createdAt",
433
+ updatedAt: "updatedAt",
434
+ },
435
+ });
436
+
437
+ const userRepository = orm.getRepository(User);
438
+
439
+ // Create a user (createdAt and updatedAt set automatically)
440
+ const newUser = await userRepository.create({ email: "lwazicd@icloud.com" });
441
+ console.log(newUser.createdAt, newUser.updatedAt); // Outputs current timestamp
442
+
443
+ // Update a user (updatedAt updated automatically)
444
+ const updatedUser = await userRepository.update(newUser.id, { email: "admin@offbytesecure.com" });
445
+ console.log(updatedUser.updatedAt); // Outputs new timestamp
446
+
447
+ // Bulk create users
448
+ const newUsers = await userRepository.bulkCreate([
449
+ { email: "user1@example.com" },
450
+ { email: "user2@example.com" },
451
+ ]);
452
+ console.log(newUsers.map(u => u.createdAt)); // Outputs timestamps for each user
453
+ ```
454
+
366
455
  ---
367
456
 
368
457
  ## 🗑️ Soft Deletes
@@ -452,6 +541,6 @@ Licensed under the MIT License. See [LICENSE.md](./LICENSE.md) for details.
452
541
 
453
542
  Created with ❤️ by **ElectronSz**
454
543
  <br/>
455
- <em>File last updated: 2025-10-18 22:10:00 SAST</em>
544
+ <em>File last updated: 2025-10-19 11:12:00 SAST</em>
456
545
 
457
546
  </div>
package/client.ts CHANGED
@@ -12,20 +12,38 @@ import {
12
12
  StabilizeError,
13
13
  DBType,
14
14
  } from "./types";
15
- import { type Logger, ConsoleLogger } from "./logger";
15
+ import { type Logger, StabilizeLogger } from "./logger";
16
16
 
17
+ /**
18
+ * Checks if the DB configuration is for SQLite.
19
+ * @param config The database configuration object.
20
+ * @returns True if the configuration is for SQLite, false otherwise.
21
+ */
17
22
  function isSQLiteConfig(config: DBConfig): boolean {
18
23
  return config.type === DBType.SQLite;
19
24
  }
20
25
 
26
+ /**
27
+ * Checks if the DB configuration is for MySQL.
28
+ * @param config The database configuration object.
29
+ * @returns True if the configuration is for MySQL, false otherwise.
30
+ */
21
31
  function isMySQLConfig(config: DBConfig): boolean {
22
32
  return config.type === DBType.MySQL;
23
33
  }
24
34
 
35
+ /**
36
+ * Checks if the given client is a MySQL pool.
37
+ * @param client The database client.
38
+ * @returns True if the client is a MySQL pool, false otherwise.
39
+ */
25
40
  function isMySQLPool(client: any): client is mysql.Pool {
26
41
  return typeof client.getConnection === 'function';
27
42
  }
28
43
 
44
+ /**
45
+ * Provides a unified database client for interacting with PostgreSQL, MySQL, and SQLite.
46
+ */
29
47
  export class DBClient {
30
48
  private client!: Database | Pool | mysql.Pool | PoolClient | mysql.PoolConnection;
31
49
  private logger: Logger;
@@ -37,9 +55,15 @@ export class DBClient {
37
55
  private preparedStatements: Map<string, Statement> = new Map();
38
56
  public readonly isTransactionClient: boolean = false;
39
57
 
58
+ /**
59
+ * Constructs a new DBClient instance.
60
+ * @param config The database configuration object.
61
+ * @param logger Optional logger instance. Uses StabilizeLogger if not provided.
62
+ * @param existingClient Optional existing transaction client.
63
+ */
40
64
  constructor(
41
65
  config: DBConfig,
42
- logger: Logger = new ConsoleLogger(),
66
+ logger: Logger = new StabilizeLogger(),
43
67
  existingClient: PoolClient | mysql.PoolConnection | null = null,
44
68
  ) {
45
69
  this.config = config;
@@ -56,6 +80,10 @@ export class DBClient {
56
80
  }
57
81
  }
58
82
 
83
+ /**
84
+ * Initializes the database client based on the configuration.
85
+ * @param config The database configuration object.
86
+ */
59
87
  private initializeClient(config: DBConfig) {
60
88
  if (isSQLiteConfig(config)) {
61
89
  this.client = new Database(config.connectionString, { create: true });
@@ -63,14 +91,25 @@ export class DBClient {
63
91
  } else if (isMySQLConfig(config)) {
64
92
  this.client = mysql.createPool(config.connectionString);
65
93
  this.logger.logDebug(`Initialized MySQL Pool client.`);
66
- } else if (config.type = DBType.Postgres) {
94
+ } else if (config.type = DBType.Postgres) { // NOTE: single '=' should be '===', this is likely a bug
67
95
  this.client = new Pool({ connectionString: config.connectionString! });
68
96
  this.logger.logDebug(`Initialized Postgres Pool client.`);
69
97
  }
70
98
  }
71
99
 
100
+ /**
101
+ * Returns a random jitter value for retry logic.
102
+ * @returns A random number up to maxJitter.
103
+ */
72
104
  private getJitter = () => Math.random() * this.maxJitter;
73
105
 
106
+ /**
107
+ * Executes a SQL query with retries and returns the resulting rows.
108
+ * @param query The SQL query string.
109
+ * @param params Query parameters.
110
+ * @returns Array of resulting rows.
111
+ * @throws StabilizeError if all retry attempts fail.
112
+ */
74
113
  async query<T>(query: string, params: any[] = []): Promise<T[]> {
75
114
  const start = Date.now();
76
115
 
@@ -113,6 +152,13 @@ export class DBClient {
113
152
  throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
114
153
  }
115
154
 
155
+ /**
156
+ * Runs a callback within a database transaction.
157
+ * Handles commit/rollback and connection release.
158
+ * @param callback The callback to execute within the transaction context.
159
+ * @returns The result of the callback.
160
+ * @throws StabilizeError if transactions are not supported or rollback is triggered.
161
+ */
116
162
  async transaction<T>(callback: (txClient: DBClient) => Promise<T>): Promise<T> {
117
163
  if (this.isTransactionClient) return callback(this);
118
164
 
@@ -160,6 +206,11 @@ export class DBClient {
160
206
  throw new StabilizeError("Transaction not supported by this client configuration.", "TX_ERROR");
161
207
  }
162
208
 
209
+ /**
210
+ * Closes the database connection.
211
+ * For pooled connections, ends the pool.
212
+ * @returns Promise that resolves once the connection is closed.
213
+ */
163
214
  async close() {
164
215
  if (this.client instanceof Database) {
165
216
  this.client.close();
@@ -170,6 +221,13 @@ export class DBClient {
170
221
  this.logger.logInfo("Database connection closed");
171
222
  }
172
223
 
224
+ /**
225
+ * Executes a migration query (DDL or DML statement) without returning results.
226
+ * Handles parameterized queries and statement preparation.
227
+ * @param query The SQL query string.
228
+ * @param params Query parameters.
229
+ * @returns Promise that resolves once the query is complete.
230
+ */
173
231
  async migrationQuery(query: string, params: any[] = []): Promise<void> {
174
232
  const start = Date.now();
175
233
  this.logger.logQuery(query, params);
@@ -183,7 +241,7 @@ export class DBClient {
183
241
  stmt.run(...params);
184
242
  } else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) {
185
243
  await (this.client as mysql.Pool).query(query, params);
186
- } else if (this.config.type = DBType.Postgres) {
244
+ } else if (this.config.type = DBType.Postgres) { // NOTE: single '=' should be '===', this is likely a bug
187
245
  let paramIndex = 0;
188
246
  const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
189
247
  await (this.client as Pool).query(pgQuery, params);
@@ -192,4 +250,4 @@ export class DBClient {
192
250
  const executionTime = Date.now() - start;
193
251
  this.logger.logQuery(query, params, executionTime);
194
252
  }
195
- }
253
+ }
package/logger.ts CHANGED
@@ -27,7 +27,7 @@ export interface Logger {
27
27
  /**
28
28
  * A logger implementation that writes to the console and can optionally write to rotating files.
29
29
  */
30
- export class ConsoleLogger implements Logger {
30
+ export class StabilizeLogger implements Logger {
31
31
  private readonly level: LogLevel;
32
32
  private readonly filePath: string | null;
33
33
  private readonly maxFileSize: number;
package/model.ts CHANGED
@@ -1,11 +1,11 @@
1
-
2
1
  /**
3
2
  * @file model.ts
4
3
  * @description Provides a programmatic API for defining models and a metadata storage system.
5
4
  * @author ElectronSz
6
5
  */
7
6
 
8
- import { DataTypes, RelationType, DBType } from './types';
7
+ import type { QueryBuilder } from './query-builder';
8
+ import { DataTypes, RelationType } from './types';
9
9
 
10
10
  // Interface for column configuration
11
11
  export interface ColumnConfig {
@@ -31,6 +31,11 @@ export interface RelationConfig {
31
31
  joinTable?: string;
32
32
  }
33
33
 
34
+ export interface TimestampsConfig {
35
+ createdAt?: string;
36
+ updatedAt?: string;
37
+ }
38
+
34
39
  // Interface for model configuration
35
40
  export interface ModelConfig {
36
41
  tableName: string;
@@ -38,28 +43,59 @@ export interface ModelConfig {
38
43
  softDelete?: boolean;
39
44
  columns: Record<string, ColumnConfig>;
40
45
  relations?: RelationConfig[];
46
+ scopes?: Record<string, (qb: QueryBuilder<any>, ...args: any[]) => QueryBuilder<any>>; // Custom query scopes
47
+ timestamps?: TimestampsConfig; // Auto-managed timestamp columns
41
48
  }
42
49
 
43
- // Metadata storage for models
50
+
51
+ /**
52
+ * Metadata storage for models.
53
+ * Stores and retrieves model configuration such as columns, relations, scopes, etc.
54
+ */
44
55
  export class MetadataStorage {
45
56
  private static models: Map<Function, ModelConfig> = new Map();
46
57
 
58
+ /**
59
+ * Associates model metadata with a class constructor.
60
+ * @param model - The class constructor for the model.
61
+ * @param config - The model configuration object.
62
+ */
47
63
  static setModelMetadata(model: Function, config: ModelConfig) {
48
64
  this.models.set(model, config);
49
65
  }
50
66
 
67
+ /**
68
+ * Retrieves the model configuration for a given model class.
69
+ * @param model - The class constructor for the model.
70
+ * @returns The model configuration or undefined if not found.
71
+ */
51
72
  static getModelMetadata(model: Function): ModelConfig | undefined {
52
73
  return this.models.get(model);
53
74
  }
54
75
 
76
+ /**
77
+ * Gets the table name for a given model class.
78
+ * @param model - The class constructor for the model.
79
+ * @returns The table name or an empty string if not found.
80
+ */
55
81
  static getTableName(model: Function): string {
56
82
  return this.getModelMetadata(model)?.tableName || '';
57
83
  }
58
84
 
85
+ /**
86
+ * Gets the column configuration for a given model class.
87
+ * @param model - The class constructor for the model.
88
+ * @returns Record of column names to their configuration.
89
+ */
59
90
  static getColumns(model: Function): Record<string, ColumnConfig> {
60
91
  return this.getModelMetadata(model)?.columns || {};
61
92
  }
62
93
 
94
+ /**
95
+ * Collects validation rules for each column of a given model.
96
+ * @param model - The class constructor for the model.
97
+ * @returns An object mapping column names to an array of validation rule names.
98
+ */
63
99
  static getValidators(model: Function): Record<string, string[]> {
64
100
  const columns = this.getModelMetadata(model)?.columns || {};
65
101
  const validators: Record<string, string[]> = {};
@@ -72,6 +108,11 @@ export class MetadataStorage {
72
108
  return validators;
73
109
  }
74
110
 
111
+ /**
112
+ * Gets the relationship configuration for a given model class.
113
+ * @param model - The class constructor for the model.
114
+ * @returns Record of property names to their relation configuration.
115
+ */
75
116
  static getRelations(model: Function): Record<string, RelationConfig> {
76
117
  const relations = this.getModelMetadata(model)?.relations || [];
77
118
  const result: Record<string, RelationConfig> = {};
@@ -81,6 +122,11 @@ export class MetadataStorage {
81
122
  return result;
82
123
  }
83
124
 
125
+ /**
126
+ * Finds the soft delete field, if any, for a given model class.
127
+ * @param model - The class constructor for the model.
128
+ * @returns The key of the soft delete field, or null if not found.
129
+ */
84
130
  static getSoftDeleteField(model: Function): string | null {
85
131
  const columns = this.getModelMetadata(model)?.columns || {};
86
132
  for (const [key, col] of Object.entries(columns)) {
@@ -89,14 +135,40 @@ export class MetadataStorage {
89
135
  return null;
90
136
  }
91
137
 
138
+ /**
139
+ * Checks if the model is versioned.
140
+ * @param model - The class constructor for the model.
141
+ * @returns True if versioned, false otherwise.
142
+ */
92
143
  static isVersioned(model: Function): boolean {
93
144
  return !!this.getModelMetadata(model)?.versioned;
94
145
  }
146
+
147
+ /**
148
+ * Gets custom query scopes for a given model class.
149
+ * @param model - The class constructor for the model.
150
+ * @returns Record of scope names to scope functions.
151
+ */
152
+ static getScopes(model: Function): Record<string, (qb: QueryBuilder<any>, ...args: any[]) => QueryBuilder<any>> {
153
+ return this.getModelMetadata(model)?.scopes || {};
154
+ }
155
+
156
+ static getTimestamps(model: Function): TimestampsConfig {
157
+ return this.getModelMetadata(model)?.timestamps || {};
158
+ }
95
159
  }
96
160
 
97
- // Programmatic model definition
161
+ /**
162
+ * Programmatically defines a model and stores its metadata.
163
+ * @param config - The model configuration object.
164
+ * @returns The dynamically created model class.
165
+ */
98
166
  export function defineModel(config: ModelConfig) {
99
167
  class Model {
168
+ /**
169
+ * Constructs a model instance from plain data.
170
+ * @param data - The plain object to assign properties from.
171
+ */
100
172
  constructor(data: any) {
101
173
  Object.assign(this, data);
102
174
  }
@@ -109,7 +181,9 @@ export function defineModel(config: ModelConfig) {
109
181
  softDelete: config.softDelete || false,
110
182
  columns: config.columns,
111
183
  relations: config.relations || [],
184
+ scopes: config.scopes || {},
185
+ timestamps: config.timestamps || {},
112
186
  });
113
187
 
114
188
  return Model;
115
- }
189
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.3.0",
3
+ "version": "1.3.3",
4
4
  "description": "A lightweight, type-safe ORM for Bun.js with support for SQLite, MySQL, PostgreSQL, and Redis caching",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/query-builder.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { DBClient } from "./client";
8
8
  import { Cache } from "./cache";
9
+ import { MetadataStorage } from "./model";
10
+ import { StabilizeError } from "./types";
9
11
 
10
12
  /**
11
13
  * A fluent interface for building SQL SELECT queries.
@@ -125,7 +127,7 @@ export class QueryBuilder<T> {
125
127
  */
126
128
  build(): { query: string; params: any[] } {
127
129
  let query = `SELECT ${this.selectFields.join(", ")} FROM ${this.table}`;
128
-
130
+
129
131
  if (this.joins.length > 0) {
130
132
  query += " " + this.joins.join(" ");
131
133
  }
@@ -166,7 +168,7 @@ export class QueryBuilder<T> {
166
168
  cacheKey?: string,
167
169
  ): Promise<T[]> {
168
170
  const { query, params } = this.build();
169
-
171
+
170
172
  // Attempt to retrieve from cache first (cache-aside read)
171
173
  if (cache && cacheKey) {
172
174
  const cached = await cache.get<T[]>(cacheKey);
@@ -178,9 +180,30 @@ export class QueryBuilder<T> {
178
180
 
179
181
  // Store the database results in the cache for future requests
180
182
  if (cache && cacheKey && results.length > 0) {
181
- await cache.set(cacheKey, results, 60);
183
+ await cache.set(cacheKey, results, 60);
182
184
  }
183
-
185
+
184
186
  return results;
185
187
  }
188
+
189
+ /**
190
+ * Applies a named scope to the current query builder.
191
+ *
192
+ * This method looks up a scope function by name for the current model (based on the table name),
193
+ * then invokes the scope function with the query builder and any additional arguments.
194
+ *
195
+ * @param {string} name - The name of the scope to apply.
196
+ * @param {...any} args - Additional arguments to pass to the scope function.
197
+ * @throws {StabilizeError} If no model is found for the current table, or if the specified scope does not exist.
198
+ * @returns {QueryBuilder<T>} The query builder instance after applying the scope.
199
+ */
200
+ scope(name: string, ...args: any[]): QueryBuilder<T> {
201
+ const model = Object.values(MetadataStorage['models']).find(m => m.tableName === this.table)?.constructor;
202
+ if (!model) throw new StabilizeError(`Model for table ${this.table} not found`, "SCOPE_ERROR");
203
+ const scopes = MetadataStorage.getScopes(model);
204
+ const scopeFn = scopes[name];
205
+ if (!scopeFn) throw new StabilizeError(`Scope ${name} not found`, "SCOPE_ERROR");
206
+ return scopeFn(this, ...args);
207
+ }
208
+
186
209
  }
package/repository.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Cache } from "./cache";
8
8
  import { DBClient } from "./client";
9
- import { ConsoleLogger, type Logger } from "./logger";
9
+ import { StabilizeLogger, type Logger } from "./logger";
10
10
  import { QueryBuilder } from "./query-builder";
11
11
  import {
12
12
  DataTypes,
@@ -58,7 +58,7 @@ export class Repository<T> {
58
58
  client: DBClient,
59
59
  model: new (...args: any[]) => T,
60
60
  cacheConfig: CacheConfig = { enabled: false, ttl: 60 },
61
- logger: Logger = new ConsoleLogger(),
61
+ logger: Logger = new StabilizeLogger(),
62
62
  ) {
63
63
  this.client = client;
64
64
  this.cache = cacheConfig.enabled ? new Cache(cacheConfig, logger) : null;
@@ -148,6 +148,21 @@ export class Repository<T> {
148
148
  return qb;
149
149
  }
150
150
 
151
+ /**
152
+ * Applies a custom scope to the query for the repository's table.
153
+ * @param name The name of the scope to apply.
154
+ * @param args Optional arguments to pass to the scope function.
155
+ * @returns A `QueryBuilder` instance with the scope applied.
156
+ * @example
157
+ * ```
158
+ * const activeUsers = await userRepository.scope('active').execute(client);
159
+ * ```
160
+ */
161
+ scope(name: string, ...args: any[]): QueryBuilder<T> {
162
+ this.logger.logDebug(`Applying scope ${name} to ${this.table}`);
163
+ return this.find().scope(name, ...args);
164
+ }
165
+
151
166
  /**
152
167
  * Finds a single record by its primary key (id).
153
168
  * @param id The ID of the record to find.
@@ -342,10 +357,19 @@ export class Repository<T> {
342
357
  );
343
358
  this.validate(entity);
344
359
 
345
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
360
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
361
+ const entityWithTimestamps = { ...entity } as Record<string, any>;
362
+ if (timestamps.createdAt && !entityWithTimestamps[timestamps.createdAt]) {
363
+ entityWithTimestamps[timestamps.createdAt] = new Date();
364
+ }
365
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
366
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
367
+ }
368
+
369
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
346
370
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
347
371
  const placeholders = keys.map(() => "?").join(", ");
348
- const params = keys.map((k) => (entity as any)[k]);
372
+ const params = keys.map((k) => (entityWithTimestamps as any)[k]);
349
373
  let query = `INSERT INTO ${this.table} (${columnNames}) VALUES (${placeholders})`;
350
374
 
351
375
  let insertedResult: T[] | undefined;
@@ -383,7 +407,6 @@ export class Repository<T> {
383
407
  );
384
408
  return result;
385
409
  }
386
-
387
410
  /**
388
411
  * Creates multiple records in the database in batches.
389
412
  * @param entities An array of entities to create.
@@ -444,11 +467,18 @@ export class Repository<T> {
444
467
  const batchSize = options.batchSize || 1000;
445
468
  entities.forEach((entity) => this.validate(entity));
446
469
 
470
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
471
+ const entitiesWithTimestamps = entities.map(entity => ({
472
+ ...entity,
473
+ ...(timestamps.createdAt && !(entity as Record<string, any>)[timestamps.createdAt] ? { [timestamps.createdAt]: new Date() } : {}),
474
+ ...(timestamps.updatedAt && !(entity as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
475
+ })) as Partial<T>[];
476
+
447
477
  const dbType = this.getDBType(client);
448
478
  const results: T[] = [];
449
479
 
450
- for (let i = 0; i < entities.length; i += batchSize) {
451
- const batch = entities.slice(i, i + batchSize);
480
+ for (let i = 0; i < entitiesWithTimestamps.length; i += batchSize) {
481
+ const batch = entitiesWithTimestamps.slice(i, i + batchSize);
452
482
  const keys = Object.keys(batch[0]!).filter((k) => this.columns[k]);
453
483
  const columnNames = keys.map((k) => this.columns[k]?.name).join(", ");
454
484
 
@@ -483,7 +513,6 @@ export class Repository<T> {
483
513
  finalResults = await queryBuilder.execute(client);
484
514
  }
485
515
  results.push(...finalResults);
486
-
487
516
  } else {
488
517
  const placeholders = `(${keys.map(() => "?").join(", ")})`;
489
518
  query = `INSERT INTO ${this.table} (${columnNames}) VALUES ${batch.map(() => placeholders).join(", ")}`;
@@ -567,7 +596,13 @@ export class Repository<T> {
567
596
  this.logger.logDebug(`Updating ${this.table} with ID ${id}`);
568
597
  this.validate(entity);
569
598
 
570
- const keys = Object.keys(entity).filter((k) => this.columns[k]);
599
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
600
+ const entityWithTimestamps = { ...entity } as Record<string, any>;;
601
+ if (timestamps.updatedAt && !entityWithTimestamps[timestamps.updatedAt]) {
602
+ entityWithTimestamps[timestamps.updatedAt] = new Date();
603
+ }
604
+
605
+ const keys = Object.keys(entityWithTimestamps).filter((k) => this.columns[k]);
571
606
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
572
607
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
573
608
  const params = [...keys.map((k) => (entity as any)[k]), id];
@@ -630,6 +665,8 @@ export class Repository<T> {
630
665
  const batchSize = options.batchSize || 1000;
631
666
  updates.forEach((update) => this.validate(update.set));
632
667
 
668
+ const timestamps = MetadataStorage.getTimestamps((this as any).model || Object);
669
+
633
670
  for (let i = 0; i < updates.length; i += batchSize) {
634
671
  const batch = updates.slice(i, i + batchSize);
635
672
  for (const update of batch) {
@@ -647,11 +684,16 @@ export class Repository<T> {
647
684
  await this.runHooks(instance, "beforeUpdate");
648
685
  await this.runHooks(instance, "beforeSave");
649
686
 
650
- const keys = Object.keys(update.set).filter((k) => this.columns[k]);
687
+ const updateWithTimestamps = {
688
+ ...update.set,
689
+ ...(timestamps.updatedAt && !(update.set as Record<string, any>)[timestamps.updatedAt] ? { [timestamps.updatedAt]: new Date() } : {}),
690
+ } as Partial<T>;
691
+
692
+ const keys = Object.keys(updateWithTimestamps).filter((k) => this.columns[k]);
651
693
  const setClause = keys.map((k) => `${this.columns[k]?.name} = ?`).join(", ");
652
694
  const query = `UPDATE ${this.table} SET ${setClause} WHERE id = ?${this.softDeleteField ? ` AND ${this.softDeleteField} IS NULL` : ""}`;
653
695
  const params = [
654
- ...keys.map((k) => (update.set as any)[k]),
696
+ ...keys.map((k) => (updateWithTimestamps as any)[k]),
655
697
  id,
656
698
  ];
657
699
  await client.query(query, params);
package/types.ts CHANGED
@@ -42,6 +42,7 @@ export enum DataTypes {
42
42
  JSON, // Maps to JSON, JSONB, or TEXT
43
43
  UUID, // Maps to UUID or VARCHAR(36)
44
44
  BLOB, // Maps to BYTEA or BLOB
45
+
45
46
  }
46
47
 
47
48
  export interface DBConfig {