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 +24 -0
- package/README.md +91 -2
- package/client.ts +63 -5
- package/logger.ts +1 -1
- package/model.ts +79 -5
- package/package.json +1 -1
- package/query-builder.ts +27 -4
- package/repository.ts +53 -11
- package/types.ts +1 -0
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 `
|
|
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-
|
|
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,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
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 {
|
|
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
|
|
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
|
|
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) => (
|
|
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 <
|
|
451
|
-
const batch =
|
|
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
|
|
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
|
|
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) => (
|
|
696
|
+
...keys.map((k) => (updateWithTimestamps as any)[k]),
|
|
655
697
|
id,
|
|
656
698
|
];
|
|
657
699
|
await client.query(query, params);
|