stabilize-orm 1.1.8 → 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/CHANGELOG.md +33 -5
- package/README.md +253 -143
- package/bun.lock +0 -3
- package/client.ts +7 -82
- package/hooks.ts +76 -0
- package/index.ts +8 -36
- package/migrations.ts +64 -74
- package/model.ts +115 -0
- package/package.json +3 -9
- package/repository.ts +330 -47
- package/decorators.ts +0 -165
package/client.ts
CHANGED
|
@@ -14,26 +14,18 @@ import {
|
|
|
14
14
|
} from "./types";
|
|
15
15
|
import { type Logger, ConsoleLogger } from "./logger";
|
|
16
16
|
|
|
17
|
-
/** @internal Checks if the config is for SQLite. */
|
|
18
17
|
function isSQLiteConfig(config: DBConfig): boolean {
|
|
19
18
|
return config.type === DBType.SQLite;
|
|
20
19
|
}
|
|
21
20
|
|
|
22
|
-
/** @internal Checks if the config is for MySQL. */
|
|
23
21
|
function isMySQLConfig(config: DBConfig): boolean {
|
|
24
22
|
return config.type === DBType.MySQL;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
/** @internal A type guard to reliably identify a mysql2 Pool object. */
|
|
28
25
|
function isMySQLPool(client: any): client is mysql.Pool {
|
|
29
26
|
return typeof client.getConnection === 'function';
|
|
30
27
|
}
|
|
31
28
|
|
|
32
|
-
/**
|
|
33
|
-
* A unified database client that provides a consistent interface for
|
|
34
|
-
* PostgreSQL, MySQL, and SQLite databases. It handles connection pooling,
|
|
35
|
-
* query execution with retries, and transactions.
|
|
36
|
-
*/
|
|
37
29
|
export class DBClient {
|
|
38
30
|
private client!: Database | Pool | mysql.Pool | PoolClient | mysql.PoolConnection;
|
|
39
31
|
private logger: Logger;
|
|
@@ -45,12 +37,6 @@ export class DBClient {
|
|
|
45
37
|
private preparedStatements: Map<string, Statement> = new Map();
|
|
46
38
|
public readonly isTransactionClient: boolean = false;
|
|
47
39
|
|
|
48
|
-
/**
|
|
49
|
-
* Creates an instance of DBClient.
|
|
50
|
-
* @param config The database configuration object.
|
|
51
|
-
* @param logger A logger instance for logging messages.
|
|
52
|
-
* @param existingClient An optional existing connection, used internally for transactions.
|
|
53
|
-
*/
|
|
54
40
|
constructor(
|
|
55
41
|
config: DBConfig,
|
|
56
42
|
logger: Logger = new ConsoleLogger(),
|
|
@@ -70,11 +56,6 @@ export class DBClient {
|
|
|
70
56
|
}
|
|
71
57
|
}
|
|
72
58
|
|
|
73
|
-
/**
|
|
74
|
-
* @internal
|
|
75
|
-
* Initializes the database client based on the provided configuration.
|
|
76
|
-
* @param config The database configuration.
|
|
77
|
-
*/
|
|
78
59
|
private initializeClient(config: DBConfig) {
|
|
79
60
|
if (isSQLiteConfig(config)) {
|
|
80
61
|
this.client = new Database(config.connectionString, { create: true });
|
|
@@ -88,22 +69,8 @@ export class DBClient {
|
|
|
88
69
|
}
|
|
89
70
|
}
|
|
90
71
|
|
|
91
|
-
/** @internal Gets a random jitter value to add to retry delays. */
|
|
92
72
|
private getJitter = () => Math.random() * this.maxJitter;
|
|
93
73
|
|
|
94
|
-
/**
|
|
95
|
-
* Executes a SQL query with parameters and returns the result.
|
|
96
|
-
* Automatically handles placeholder conversion for different databases and includes retry logic.
|
|
97
|
-
* @template T The expected type of the result rows.
|
|
98
|
-
* @param query The SQL query string with `?` as placeholders.
|
|
99
|
-
* @param params An array of parameters to bind to the query.
|
|
100
|
-
* @returns A promise that resolves to an array of results.
|
|
101
|
-
* @example
|
|
102
|
-
* ```
|
|
103
|
-
* const users = await dbClient.query('SELECT * FROM users WHERE status = ?', ['active']);
|
|
104
|
-
* ```
|
|
105
|
-
*/
|
|
106
|
-
|
|
107
74
|
async query<T>(query: string, params: any[] = []): Promise<T[]> {
|
|
108
75
|
const start = Date.now();
|
|
109
76
|
|
|
@@ -111,21 +78,19 @@ export class DBClient {
|
|
|
111
78
|
try {
|
|
112
79
|
let result: any;
|
|
113
80
|
|
|
114
|
-
// Log the query before execution
|
|
115
81
|
this.logger.logQuery(query, params);
|
|
116
82
|
|
|
117
|
-
if (this.client instanceof Database) {
|
|
83
|
+
if (this.client instanceof Database) {
|
|
118
84
|
let stmt = this.preparedStatements.get(query);
|
|
119
85
|
if (!stmt) {
|
|
120
86
|
stmt = this.client.prepare(query);
|
|
121
87
|
this.preparedStatements.set(query, stmt);
|
|
122
88
|
}
|
|
123
89
|
result = stmt.all(...params);
|
|
124
|
-
} else if (this.config.type === DBType.MySQL && isMySQLPool(this.client)) {
|
|
90
|
+
} else if (this.config.type === DBType.MySQL && isMySQLPool(this.client)) {
|
|
125
91
|
const [rows] = await (this.client as mysql.Pool).query(query, params);
|
|
126
92
|
result = rows;
|
|
127
|
-
} else if (this.config.type === DBType.Postgres ) {
|
|
128
|
-
|
|
93
|
+
} else if (this.config.type === DBType.Postgres ) {
|
|
129
94
|
let paramIndex = 0;
|
|
130
95
|
const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
|
|
131
96
|
const pgResult = await (this.client as Pool).query(pgQuery, params);
|
|
@@ -138,8 +103,6 @@ export class DBClient {
|
|
|
138
103
|
this.logger.logQuery(query, params, executionTime);
|
|
139
104
|
return Array.isArray(result) ? result as T[] : [];
|
|
140
105
|
} catch (error) {
|
|
141
|
-
console.log("error: ", error);
|
|
142
|
-
|
|
143
106
|
this.logger.logError(error as Error);
|
|
144
107
|
if (attempt === this.retryAttempts) {
|
|
145
108
|
throw new StabilizeError(`Query failed after ${this.retryAttempts} attempts: ${(error as Error).message}`, "QUERY_ERROR");
|
|
@@ -147,24 +110,9 @@ export class DBClient {
|
|
|
147
110
|
await new Promise(res => setTimeout(res, this.retryDelay * Math.pow(2, attempt - 1) + this.getJitter()));
|
|
148
111
|
}
|
|
149
112
|
}
|
|
150
|
-
// This line should theoretically be unreachable if retryAttempts >= 1
|
|
151
113
|
throw new StabilizeError("Query failed: maximum retries reached without success", "QUERY_ERROR");
|
|
152
114
|
}
|
|
153
115
|
|
|
154
|
-
/**
|
|
155
|
-
* Executes a series of database operations within a single atomic transaction.
|
|
156
|
-
* If any operation in the callback fails, the entire transaction is rolled back.
|
|
157
|
-
* @template T The return type of the callback function.
|
|
158
|
-
* @param callback An async function that receives a transactional `DBClient` instance.
|
|
159
|
-
* @returns A promise that resolves with the result of the callback.
|
|
160
|
-
* @example
|
|
161
|
-
* ```
|
|
162
|
-
* await dbClient.transaction(async (txClient) => {
|
|
163
|
-
* await txClient.query('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
|
|
164
|
-
* await txClient.query('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
|
|
165
|
-
* });
|
|
166
|
-
* ```
|
|
167
|
-
*/
|
|
168
116
|
async transaction<T>(callback: (txClient: DBClient) => Promise<T>): Promise<T> {
|
|
169
117
|
if (this.isTransactionClient) return callback(this);
|
|
170
118
|
|
|
@@ -212,10 +160,6 @@ export class DBClient {
|
|
|
212
160
|
throw new StabilizeError("Transaction not supported by this client configuration.", "TX_ERROR");
|
|
213
161
|
}
|
|
214
162
|
|
|
215
|
-
/**
|
|
216
|
-
* Closes the database connection pool gracefully.
|
|
217
|
-
* Should be called when the application is shutting down.
|
|
218
|
-
*/
|
|
219
163
|
async close() {
|
|
220
164
|
if (this.client instanceof Database) {
|
|
221
165
|
this.client.close();
|
|
@@ -226,38 +170,20 @@ export class DBClient {
|
|
|
226
170
|
this.logger.logInfo("Database connection closed");
|
|
227
171
|
}
|
|
228
172
|
|
|
229
|
-
/**
|
|
230
|
-
* Executes a SQL query for migrations/transactions that
|
|
231
|
-
* does NOT expect any result rows and returns void.
|
|
232
|
-
* This is used for DDL and transaction statements (e.g. CREATE TABLE, BEGIN, COMMIT)
|
|
233
|
-
* that should never be iterated over.
|
|
234
|
-
*
|
|
235
|
-
* Logs the execution time for each query.
|
|
236
|
-
*
|
|
237
|
-
* @param query The SQL query string with `?` as placeholders.
|
|
238
|
-
* @param params An array of parameters to bind to the query.
|
|
239
|
-
* @returns A promise that resolves when the query has executed.
|
|
240
|
-
* @example
|
|
241
|
-
* ```
|
|
242
|
-
* await dbClient.migrationQuery('CREATE TABLE ...');
|
|
243
|
-
* await dbClient.migrationQuery('BEGIN');
|
|
244
|
-
* await dbClient.migrationQuery('COMMIT');
|
|
245
|
-
* ```
|
|
246
|
-
*/
|
|
247
173
|
async migrationQuery(query: string, params: any[] = []): Promise<void> {
|
|
248
174
|
const start = Date.now();
|
|
249
175
|
this.logger.logQuery(query, params);
|
|
250
176
|
|
|
251
|
-
if (this.client instanceof Database) {
|
|
177
|
+
if (this.client instanceof Database) {
|
|
252
178
|
let stmt = this.preparedStatements.get(query);
|
|
253
179
|
if (!stmt) {
|
|
254
180
|
stmt = this.client.prepare(query);
|
|
255
181
|
this.preparedStatements.set(query, stmt);
|
|
256
182
|
}
|
|
257
183
|
stmt.run(...params);
|
|
258
|
-
} else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) {
|
|
184
|
+
} else if (isMySQLPool(this.client) || ('query' in this.client && 'release' in this.client && !(this.client instanceof Pool))) {
|
|
259
185
|
await (this.client as mysql.Pool).query(query, params);
|
|
260
|
-
} else if (this.config.type = DBType.Postgres) {
|
|
186
|
+
} else if (this.config.type = DBType.Postgres) {
|
|
261
187
|
let paramIndex = 0;
|
|
262
188
|
const pgQuery = query.replace(/\?/g, () => `$${++paramIndex}`);
|
|
263
189
|
await (this.client as Pool).query(pgQuery, params);
|
|
@@ -266,5 +192,4 @@ export class DBClient {
|
|
|
266
192
|
const executionTime = Date.now() - start;
|
|
267
193
|
this.logger.logQuery(query, params, executionTime);
|
|
268
194
|
}
|
|
269
|
-
|
|
270
|
-
}
|
|
195
|
+
}
|
package/hooks.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file hooks.ts
|
|
3
|
+
* @description Provides lifecycle hooks for Stabilize ORM models, integrated with the programmatic API.
|
|
4
|
+
* @author ElectronSz
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { MetadataStorage } from "./model";
|
|
8
|
+
|
|
9
|
+
export type HookType =
|
|
10
|
+
| "beforeCreate"
|
|
11
|
+
| "afterCreate"
|
|
12
|
+
| "beforeUpdate"
|
|
13
|
+
| "afterUpdate"
|
|
14
|
+
| "beforeSave"
|
|
15
|
+
| "afterSave"
|
|
16
|
+
| "beforeDelete"
|
|
17
|
+
| "afterDelete";
|
|
18
|
+
|
|
19
|
+
export type HookCallback = (entity: any) => Promise<void> | void;
|
|
20
|
+
|
|
21
|
+
export interface Hook {
|
|
22
|
+
type: HookType;
|
|
23
|
+
callback: HookCallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Extend ModelConfig to include hooks
|
|
27
|
+
declare module "./model" {
|
|
28
|
+
interface ModelConfig {
|
|
29
|
+
hooks?: Record<HookType, HookCallback | HookCallback[]>;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Registers hooks for a model in the MetadataStorage.
|
|
35
|
+
* @param model The model class.
|
|
36
|
+
* @param hooks A record of hook types to their callbacks.
|
|
37
|
+
*/
|
|
38
|
+
export function registerHooks(model: Function, hooks: Record<HookType, HookCallback | HookCallback[]>) {
|
|
39
|
+
const config = MetadataStorage.getModelMetadata(model) || { tableName: "", columns: {} };
|
|
40
|
+
config.hooks = { ...config.hooks, ...hooks };
|
|
41
|
+
MetadataStorage.setModelMetadata(model, config);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Retrieves hooks for a given entity and hook type.
|
|
46
|
+
* Combines hooks from MetadataStorage and class methods.
|
|
47
|
+
* @param entity The entity instance.
|
|
48
|
+
* @param type The hook type (e.g., 'beforeCreate').
|
|
49
|
+
* @returns An array of Hook objects to execute.
|
|
50
|
+
*/
|
|
51
|
+
export function getHooks(entity: any, type: HookType): Hook[] {
|
|
52
|
+
const hooks: Hook[] = [];
|
|
53
|
+
const model = Object.getPrototypeOf(entity).constructor;
|
|
54
|
+
|
|
55
|
+
// Get hooks from MetadataStorage
|
|
56
|
+
const config = MetadataStorage.getModelMetadata(model);
|
|
57
|
+
if (config?.hooks?.[type]) {
|
|
58
|
+
const callbacks = Array.isArray(config.hooks[type])
|
|
59
|
+
? config.hooks[type]
|
|
60
|
+
: [config.hooks[type]];
|
|
61
|
+
hooks.push(...callbacks.map(callback => ({
|
|
62
|
+
type,
|
|
63
|
+
callback: () => callback(entity),
|
|
64
|
+
})));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get hooks from class methods
|
|
68
|
+
if (typeof entity[type] === "function") {
|
|
69
|
+
hooks.push({
|
|
70
|
+
type,
|
|
71
|
+
callback: () => entity[type](),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return hooks;
|
|
76
|
+
}
|
package/index.ts
CHANGED
|
@@ -9,28 +9,12 @@ import { type Logger, ConsoleLogger } from "./logger";
|
|
|
9
9
|
import { QueryBuilder } from "./query-builder";
|
|
10
10
|
import { Repository } from "./repository";
|
|
11
11
|
import { runMigrations, generateMigration, type Migration } from "./migrations";
|
|
12
|
-
import {
|
|
13
|
-
Model,
|
|
14
|
-
Column,
|
|
15
|
-
Required,
|
|
16
|
-
Unique,
|
|
17
|
-
SoftDelete,
|
|
18
|
-
OneToOne,
|
|
19
|
-
ManyToOne,
|
|
20
|
-
OneToMany,
|
|
21
|
-
ManyToMany,
|
|
22
|
-
ModelKey,
|
|
23
|
-
ColumnKey,
|
|
24
|
-
ValidatorKey,
|
|
25
|
-
RelationKey,
|
|
26
|
-
SoftDeleteKey,
|
|
27
|
-
} from "./decorators";
|
|
28
12
|
import {
|
|
29
13
|
type DBConfig,
|
|
30
14
|
type CacheConfig,
|
|
31
15
|
type LoggerConfig,
|
|
32
16
|
DBType,
|
|
33
|
-
DataTypes,
|
|
17
|
+
DataTypes,
|
|
34
18
|
StabilizeError,
|
|
35
19
|
type PoolMetrics,
|
|
36
20
|
type QueryHint,
|
|
@@ -38,7 +22,8 @@ import {
|
|
|
38
22
|
type CacheStats,
|
|
39
23
|
LogLevel,
|
|
40
24
|
} from "./types";
|
|
41
|
-
|
|
25
|
+
import { defineModel } from "./model";
|
|
26
|
+
import type { Hook } from "./hooks";
|
|
42
27
|
|
|
43
28
|
export class Stabilize {
|
|
44
29
|
public client: DBClient;
|
|
@@ -66,7 +51,7 @@ export class Stabilize {
|
|
|
66
51
|
|
|
67
52
|
/**
|
|
68
53
|
* Gets a repository for a given model, used to perform CRUD operations.
|
|
69
|
-
* @param model The model class,
|
|
54
|
+
* @param model The model class, defined using `defineModel`.
|
|
70
55
|
* @returns A new `Repository` instance for the specified model.
|
|
71
56
|
* @example
|
|
72
57
|
* ```
|
|
@@ -97,7 +82,6 @@ export class Stabilize {
|
|
|
97
82
|
* try {
|
|
98
83
|
* await stabilize.transaction(async (txClient) => {
|
|
99
84
|
* const newUser = await userRepo.create({ name: 'Ciniso Dlamini' }, {}, txClient);
|
|
100
|
-
* // The new user's ID is needed for the profile, linking the operations.
|
|
101
85
|
* await profileRepo.create({ userId: newUser.id, bio: 'A new bio' }, {}, txClient);
|
|
102
86
|
* });
|
|
103
87
|
* console.log('User and profile created successfully.');
|
|
@@ -149,26 +133,13 @@ export {
|
|
|
149
133
|
Cache,
|
|
150
134
|
ConsoleLogger,
|
|
151
135
|
DBType,
|
|
152
|
-
DataTypes,
|
|
136
|
+
DataTypes,
|
|
153
137
|
LogLevel,
|
|
154
138
|
RelationType,
|
|
155
139
|
StabilizeError,
|
|
156
|
-
Model,
|
|
157
|
-
Column,
|
|
158
|
-
Required,
|
|
159
|
-
Unique,
|
|
160
|
-
SoftDelete,
|
|
161
|
-
OneToOne,
|
|
162
|
-
ManyToOne,
|
|
163
|
-
OneToMany,
|
|
164
|
-
ManyToMany,
|
|
165
|
-
ModelKey,
|
|
166
|
-
ColumnKey,
|
|
167
|
-
ValidatorKey,
|
|
168
|
-
RelationKey,
|
|
169
|
-
SoftDeleteKey,
|
|
170
140
|
runMigrations,
|
|
171
141
|
generateMigration,
|
|
142
|
+
defineModel,
|
|
172
143
|
};
|
|
173
144
|
|
|
174
145
|
export type {
|
|
@@ -180,4 +151,5 @@ export type {
|
|
|
180
151
|
PoolMetrics,
|
|
181
152
|
CacheStats,
|
|
182
153
|
Logger,
|
|
183
|
-
|
|
154
|
+
Hook
|
|
155
|
+
};
|
package/migrations.ts
CHANGED
|
@@ -6,18 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { DBClient } from "./client";
|
|
9
|
-
import {
|
|
9
|
+
import { MetadataStorage } from "./model";
|
|
10
10
|
import { type DBConfig, type Migration, StabilizeError, DBType, DataTypes } from "./types";
|
|
11
11
|
|
|
12
|
-
type ColumnData = { name: string; type: string };
|
|
13
|
-
type ColumnMetadata = Record<string, ColumnData>;
|
|
14
|
-
type ValidatorMetadata = Record<string, string[]>;
|
|
15
|
-
|
|
16
|
-
// --- FIX: New Helper Function to format queries for different DBs ---
|
|
17
12
|
/**
|
|
18
13
|
* @internal
|
|
19
14
|
* Formats a SQL query with placeholders for the target database dialect.
|
|
20
|
-
* Replaces '?' with '$1', '$2', etc. for PostgreSQL.
|
|
21
15
|
* @param query The SQL query string with '?' placeholders.
|
|
22
16
|
* @param dbType The target database dialect.
|
|
23
17
|
* @returns The formatted SQL query string.
|
|
@@ -30,19 +24,11 @@ function formatQuery(query: string, dbType: DBType): string {
|
|
|
30
24
|
return query;
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
|
|
34
27
|
/**
|
|
35
|
-
* Maps an abstract data type
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* converting each logical type to its proper SQL type in CREATE TABLE migrations.
|
|
40
|
-
*
|
|
41
|
-
* @param dt - The data type to map. Accepts either a value from the DataTypes enum, or a string
|
|
42
|
-
* (e.g., "string", "integer", "boolean", etc.).
|
|
43
|
-
* @param dbType - The target database dialect (DBType.Postgres, DBType.MySQL, or DBType.SQLite).
|
|
44
|
-
* @returns The SQL column type string appropriate for the database and logical type.
|
|
45
|
-
*
|
|
28
|
+
* Maps an abstract data type to the correct SQL type string for the specified database dialect.
|
|
29
|
+
* @param dt The data type to map.
|
|
30
|
+
* @param dbType The target database dialect.
|
|
31
|
+
* @returns The SQL column type string.
|
|
46
32
|
*/
|
|
47
33
|
function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
|
|
48
34
|
let type: string;
|
|
@@ -88,7 +74,6 @@ function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
|
|
|
88
74
|
default: return "TEXT";
|
|
89
75
|
}
|
|
90
76
|
}
|
|
91
|
-
// SQLite
|
|
92
77
|
if (dbType === DBType.SQLite) {
|
|
93
78
|
switch (type) {
|
|
94
79
|
case "string": return "TEXT";
|
|
@@ -110,7 +95,6 @@ function mapDataTypeToSql(dt: DataTypes | string, dbType: DBType): string {
|
|
|
110
95
|
return "TEXT";
|
|
111
96
|
}
|
|
112
97
|
|
|
113
|
-
|
|
114
98
|
/**
|
|
115
99
|
* @internal
|
|
116
100
|
* Gets the database-specific SQL for an auto-incrementing primary key.
|
|
@@ -130,48 +114,10 @@ function getAutoIncrementPK(dbType: DBType): string {
|
|
|
130
114
|
}
|
|
131
115
|
|
|
132
116
|
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
117
|
+
* Generates SQL migration scripts (`up` and `down`) based on a model's configuration.
|
|
118
|
+
* @param model The model class defined with `defineModel`.
|
|
119
|
+
* @param name A descriptive name for the migration.
|
|
135
120
|
* @param dbType The target database dialect.
|
|
136
|
-
* @returns The SQL string for the timestamp column type.
|
|
137
|
-
*/
|
|
138
|
-
function getTimestampType(dbType: DBType): string {
|
|
139
|
-
switch (dbType) {
|
|
140
|
-
case DBType.Postgres:
|
|
141
|
-
return "TIMESTAMP";
|
|
142
|
-
case DBType.MySQL:
|
|
143
|
-
return "DATETIME";
|
|
144
|
-
case DBType.SQLite:
|
|
145
|
-
default:
|
|
146
|
-
return "TEXT";
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* @internal
|
|
152
|
-
* Gets the database-specific SQL for a default `CURRENT_TIMESTAMP` value.
|
|
153
|
-
* @param dbType The target database dialect.
|
|
154
|
-
* @returns The SQL string for the default value.
|
|
155
|
-
*/
|
|
156
|
-
function getTimestampDefault(dbType: DBType): string {
|
|
157
|
-
switch (dbType) {
|
|
158
|
-
case DBType.Postgres:
|
|
159
|
-
case DBType.SQLite:
|
|
160
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
161
|
-
case DBType.MySQL:
|
|
162
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
163
|
-
default:
|
|
164
|
-
return "DEFAULT CURRENT_TIMESTAMP";
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Generates SQL migration scripts (`up` and `down`) based on a model's decorators.
|
|
170
|
-
* This function reads the metadata from a model class to create a `CREATE TABLE` statement.
|
|
171
|
-
*
|
|
172
|
-
* @param model The model class decorated with `@Model` and `@Column`.
|
|
173
|
-
* @param name A descriptive name for the migration (used for the migration object).
|
|
174
|
-
* @param dbType The target database dialect to generate SQL for. Defaults to Postgres.
|
|
175
121
|
* @returns A promise that resolves to a `Migration` object containing the `up` and `down` SQL scripts.
|
|
176
122
|
*/
|
|
177
123
|
export async function generateMigration(
|
|
@@ -179,13 +125,14 @@ export async function generateMigration(
|
|
|
179
125
|
name: string,
|
|
180
126
|
dbType: DBType,
|
|
181
127
|
): Promise<Migration> {
|
|
182
|
-
const tableName =
|
|
128
|
+
const tableName = MetadataStorage.getTableName(model);
|
|
183
129
|
if (!tableName) {
|
|
184
|
-
throw new StabilizeError("Model not
|
|
130
|
+
throw new StabilizeError("Model not defined with tableName", "MIGRATION_ERROR");
|
|
185
131
|
}
|
|
186
132
|
|
|
187
|
-
const columns
|
|
188
|
-
const validators
|
|
133
|
+
const columns = MetadataStorage.getColumns(model);
|
|
134
|
+
const validators = MetadataStorage.getValidators(model);
|
|
135
|
+
const versioned = MetadataStorage.isVersioned(model);
|
|
189
136
|
|
|
190
137
|
const columnDefs: string[] = [];
|
|
191
138
|
|
|
@@ -196,7 +143,7 @@ export async function generateMigration(
|
|
|
196
143
|
defParts.push("id");
|
|
197
144
|
defParts.push(getAutoIncrementPK(dbType));
|
|
198
145
|
} else {
|
|
199
|
-
defParts.push(col.name);
|
|
146
|
+
defParts.push(col.name || key);
|
|
200
147
|
defParts.push(mapDataTypeToSql(col.type, dbType));
|
|
201
148
|
}
|
|
202
149
|
|
|
@@ -206,19 +153,65 @@ export async function generateMigration(
|
|
|
206
153
|
if (validators[key]?.includes("unique")) {
|
|
207
154
|
defParts.push("UNIQUE");
|
|
208
155
|
}
|
|
156
|
+
if (col.defaultValue !== undefined) {
|
|
157
|
+
defParts.push(`DEFAULT ${JSON.stringify(col.defaultValue)}`);
|
|
158
|
+
}
|
|
159
|
+
if (col.index) {
|
|
160
|
+
defParts.push(`INDEX ${col.index}`);
|
|
161
|
+
}
|
|
209
162
|
|
|
210
163
|
columnDefs.push(defParts.join(" "));
|
|
211
164
|
}
|
|
212
165
|
|
|
213
|
-
const up = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
|
|
214
|
-
const down = [`DROP TABLE IF EXISTS ${tableName}`];
|
|
166
|
+
const up: string[] = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
|
|
167
|
+
const down: string[] = [`DROP TABLE IF EXISTS ${tableName}`];
|
|
168
|
+
|
|
169
|
+
if (versioned) {
|
|
170
|
+
const [historyUp, historyDown] = generateHistoryMigration(tableName, columnDefs, dbType);
|
|
171
|
+
up.push(historyUp);
|
|
172
|
+
down.push(historyDown);
|
|
173
|
+
}
|
|
215
174
|
|
|
216
175
|
return { up, down, name };
|
|
217
176
|
}
|
|
218
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Generates SQL for a version/audit history table for time-travel queries.
|
|
180
|
+
* @param tableName The name of the main table.
|
|
181
|
+
* @param columnDefs The column definitions (from the main table).
|
|
182
|
+
* @param dbType The target database dialect.
|
|
183
|
+
*/
|
|
184
|
+
function generateHistoryMigration(
|
|
185
|
+
tableName: string,
|
|
186
|
+
columnDefs: string[],
|
|
187
|
+
dbType: DBType,
|
|
188
|
+
): [string, string] {
|
|
189
|
+
const historyTable = `${tableName}_history`;
|
|
190
|
+
let opType = "VARCHAR(10) NOT NULL";
|
|
191
|
+
let versionType = "INT NOT NULL";
|
|
192
|
+
let tsType = dbType === DBType.MySQL ? "DATETIME" :
|
|
193
|
+
dbType === DBType.SQLite ? "TEXT" : "TIMESTAMP";
|
|
194
|
+
let modByType = dbType === DBType.MySQL ? "VARCHAR(255)" : "TEXT";
|
|
195
|
+
let modAtType = tsType + (dbType === DBType.Postgres ? " DEFAULT CURRENT_TIMESTAMP" : "");
|
|
196
|
+
|
|
197
|
+
const historyColumns = [
|
|
198
|
+
...columnDefs,
|
|
199
|
+
`operation ${opType}`,
|
|
200
|
+
`version ${versionType}`,
|
|
201
|
+
`valid_from ${tsType} NOT NULL`,
|
|
202
|
+
`valid_to ${tsType}`,
|
|
203
|
+
`modified_by ${modByType}`,
|
|
204
|
+
`modified_at ${modAtType}`
|
|
205
|
+
];
|
|
206
|
+
return [
|
|
207
|
+
`CREATE TABLE IF NOT EXISTS ${historyTable} (${historyColumns.join(", ")})`,
|
|
208
|
+
`DROP TABLE IF EXISTS ${historyTable}`
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
|
|
219
212
|
/**
|
|
220
213
|
* @internal
|
|
221
|
-
* Gets the database-specific SQL for creating the `migrations` table
|
|
214
|
+
* Gets the database-specific SQL for creating the `migrations` table.
|
|
222
215
|
* @param dbType The target database dialect.
|
|
223
216
|
* @returns The SQL string for the `CREATE TABLE` statement.
|
|
224
217
|
*/
|
|
@@ -248,9 +241,6 @@ function getMigrationsTableSQL(dbType: DBType): string {
|
|
|
248
241
|
|
|
249
242
|
/**
|
|
250
243
|
* Connects to the database and runs all pending migrations.
|
|
251
|
-
* It tracks which migrations have been applied by using a `migrations` table in the database.
|
|
252
|
-
* Each migration is run within a transaction to ensure atomicity.
|
|
253
|
-
*
|
|
254
244
|
* @param config The database configuration object.
|
|
255
245
|
* @param migrations An array of `Migration` objects to be executed.
|
|
256
246
|
*/
|
|
@@ -288,4 +278,4 @@ export async function runMigrations(config: DBConfig, migrations: Migration[]) {
|
|
|
288
278
|
}
|
|
289
279
|
}
|
|
290
280
|
|
|
291
|
-
export type { Migration };
|
|
281
|
+
export type { Migration };
|
package/model.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @file model.ts
|
|
4
|
+
* @description Provides a programmatic API for defining models and a metadata storage system.
|
|
5
|
+
* @author ElectronSz
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { DataTypes, RelationType, DBType } from './types';
|
|
9
|
+
|
|
10
|
+
// Interface for column configuration
|
|
11
|
+
export interface ColumnConfig {
|
|
12
|
+
name?: string;
|
|
13
|
+
type: DataTypes;
|
|
14
|
+
length?: number;
|
|
15
|
+
precision?: number;
|
|
16
|
+
scale?: number;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
unique?: boolean;
|
|
19
|
+
defaultValue?: any;
|
|
20
|
+
index?: string; // Optional index name
|
|
21
|
+
softDelete?: boolean; // Marks column as soft delete field
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Interface for relationship configuration
|
|
25
|
+
export interface RelationConfig {
|
|
26
|
+
type: RelationType;
|
|
27
|
+
target: () => any; // Reference to another model
|
|
28
|
+
property: string; // Property name in the model
|
|
29
|
+
foreignKey?: string;
|
|
30
|
+
inverseKey?: string;
|
|
31
|
+
joinTable?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Interface for model configuration
|
|
35
|
+
export interface ModelConfig {
|
|
36
|
+
tableName: string;
|
|
37
|
+
versioned?: boolean;
|
|
38
|
+
softDelete?: boolean;
|
|
39
|
+
columns: Record<string, ColumnConfig>;
|
|
40
|
+
relations?: RelationConfig[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Metadata storage for models
|
|
44
|
+
export class MetadataStorage {
|
|
45
|
+
private static models: Map<Function, ModelConfig> = new Map();
|
|
46
|
+
|
|
47
|
+
static setModelMetadata(model: Function, config: ModelConfig) {
|
|
48
|
+
this.models.set(model, config);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static getModelMetadata(model: Function): ModelConfig | undefined {
|
|
52
|
+
return this.models.get(model);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static getTableName(model: Function): string {
|
|
56
|
+
return this.getModelMetadata(model)?.tableName || '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static getColumns(model: Function): Record<string, ColumnConfig> {
|
|
60
|
+
return this.getModelMetadata(model)?.columns || {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
static getValidators(model: Function): Record<string, string[]> {
|
|
64
|
+
const columns = this.getModelMetadata(model)?.columns || {};
|
|
65
|
+
const validators: Record<string, string[]> = {};
|
|
66
|
+
for (const [key, col] of Object.entries(columns)) {
|
|
67
|
+
const rules: string[] = [];
|
|
68
|
+
if (col.required) rules.push('required');
|
|
69
|
+
if (col.unique) rules.push('unique');
|
|
70
|
+
validators[key] = rules;
|
|
71
|
+
}
|
|
72
|
+
return validators;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
static getRelations(model: Function): Record<string, RelationConfig> {
|
|
76
|
+
const relations = this.getModelMetadata(model)?.relations || [];
|
|
77
|
+
const result: Record<string, RelationConfig> = {};
|
|
78
|
+
for (const rel of relations) {
|
|
79
|
+
result[rel.property] = rel;
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static getSoftDeleteField(model: Function): string | null {
|
|
85
|
+
const columns = this.getModelMetadata(model)?.columns || {};
|
|
86
|
+
for (const [key, col] of Object.entries(columns)) {
|
|
87
|
+
if (col.softDelete) return key;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static isVersioned(model: Function): boolean {
|
|
93
|
+
return !!this.getModelMetadata(model)?.versioned;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Programmatic model definition
|
|
98
|
+
export function defineModel(config: ModelConfig) {
|
|
99
|
+
class Model {
|
|
100
|
+
constructor(data: any) {
|
|
101
|
+
Object.assign(this, data);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Store metadata
|
|
106
|
+
MetadataStorage.setModelMetadata(Model, {
|
|
107
|
+
tableName: config.tableName,
|
|
108
|
+
versioned: config.versioned || false,
|
|
109
|
+
softDelete: config.softDelete || false,
|
|
110
|
+
columns: config.columns,
|
|
111
|
+
relations: config.relations || [],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return Model;
|
|
115
|
+
}
|