stabilize-orm 1.1.3 → 1.1.5

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/migrations.ts CHANGED
@@ -1,3 +1,9 @@
1
+ /**
2
+ * @file migrations.ts
3
+ * @description Contains functions for generating and running database migrations based on model metadata.
4
+ * @author ElectronSz
5
+ */
6
+
1
7
  import { DBClient } from "./client";
2
8
  import { ModelKey, ColumnKey, ValidatorKey, SoftDeleteKey } from "./decorators";
3
9
  import { type DBConfig, type Migration, StabilizeError, DBType } from "./types";
@@ -6,138 +12,224 @@ type ColumnData = { name: string; type: string };
6
12
  type ColumnMetadata = Record<string, ColumnData>;
7
13
  type ValidatorMetadata = Record<string, string[]>;
8
14
 
9
- // Helper to get SQL type for auto-increment PK
10
- function getAutoIncrementPK(dbType: DBType) {
15
+ /**
16
+ * @internal
17
+ * Gets the database-specific SQL for an auto-incrementing primary key.
18
+ * @param dbType The target database dialect.
19
+ * @returns The SQL string for the primary key column definition.
20
+ */
21
+ function getAutoIncrementPK(dbType: DBType): string {
11
22
  switch (dbType) {
12
23
  case DBType.Postgres:
13
24
  return "SERIAL PRIMARY KEY";
14
25
  case DBType.MySQL:
15
26
  return "INT AUTO_INCREMENT PRIMARY KEY";
16
27
  case DBType.SQLite:
28
+ default:
17
29
  return "INTEGER PRIMARY KEY AUTOINCREMENT";
18
- default: // Default to Postgres
19
- return "SERIAL PRIMARY KEY";
20
30
  }
21
31
  }
22
32
 
23
- // Helper to get SQL type for timestamps
24
- function getTimestampType(dbType: DBType) {
33
+ /**
34
+ * @internal
35
+ * Gets the database-specific SQL for a timestamp column.
36
+ * @param dbType The target database dialect.
37
+ * @returns The SQL string for the timestamp column type.
38
+ */
39
+ function getTimestampType(dbType: DBType): string {
25
40
  switch (dbType) {
26
41
  case DBType.Postgres:
27
42
  return "TIMESTAMP";
28
43
  case DBType.MySQL:
29
44
  return "DATETIME";
30
45
  case DBType.SQLite:
31
- return "TEXT";
32
- default: // Default to Postgres
33
- return "TIMESTAMP";
46
+ default:
47
+ return "TEXT";
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @internal
53
+ * Gets the database-specific SQL for a default `CURRENT_TIMESTAMP` value.
54
+ * @param dbType The target database dialect.
55
+ * @returns The SQL string for the default value.
56
+ */
57
+ function getTimestampDefault(dbType: DBType): string {
58
+ switch (dbType) {
59
+ case DBType.Postgres:
60
+ case DBType.SQLite:
61
+ return "DEFAULT CURRENT_TIMESTAMP";
62
+ case DBType.MySQL:
63
+ return "DEFAULT CURRENT_TIMESTAMP";
64
+ default:
65
+ return "DEFAULT CURRENT_TIMESTAMP";
34
66
  }
35
67
  }
36
68
 
69
+ /**
70
+ * Generates SQL migration scripts (`up` and `down`) based on a model's decorators.
71
+ * This function reads the metadata from a model class to create a `CREATE TABLE` statement.
72
+ *
73
+ * @param model The model class decorated with `@Model` and `@Column`.
74
+ * @param name A descriptive name for the migration (used for the migration object).
75
+ * @param dbType The target database dialect to generate SQL for. Defaults to Postgres.
76
+ * @returns A promise that resolves to a `Migration` object containing the `up` and `down` SQL scripts.
77
+ * @example
78
+ * ```
79
+ * // In a script like 'scripts/generate_user_migration.ts'
80
+ * import { generateMigration, DBType } from 'stabilize-orm';
81
+ * import { User } from './models/user';
82
+ * import fs from 'fs';
83
+ *
84
+ * async function createMigration() {
85
+ * const migration = await generateMigration(User, 'create_users_table', DBType.Postgres);
86
+ * fs.writeFileSync(
87
+ * `migrations/${new Date().getTime()}_create_users.json`,
88
+ * JSON.stringify(migration, null, 2)
89
+ * );
90
+ * }
91
+ *
92
+ * createMigration();
93
+ * ```
94
+ */
37
95
  export async function generateMigration(
38
96
  model: new (...args: any[]) => any,
39
- name: string,
40
- dbType: DBType = DBType.Postgres, // default to Postgres
97
+ name: string,
98
+ dbType: DBType = DBType.Postgres,
41
99
  ): Promise<Migration> {
42
100
  const tableName = Reflect.getMetadata(ModelKey, model);
43
- if (!tableName)
44
- throw new StabilizeError(
45
- "Model not decorated with @Model",
46
- "MIGRATION_ERROR",
47
- );
48
-
49
- const columns: ColumnMetadata =
50
- Reflect.getMetadata(ColumnKey, model.prototype) || {};
51
- const validators: ValidatorMetadata =
52
- Reflect.getMetadata(ValidatorKey, model.prototype) || {};
101
+ if (!tableName) {
102
+ throw new StabilizeError("Model not decorated with @Model", "MIGRATION_ERROR");
103
+ }
104
+
105
+ const columns: ColumnMetadata = Reflect.getMetadata(ColumnKey, model.prototype) || {};
106
+ const validators: ValidatorMetadata = Reflect.getMetadata(ValidatorKey, model.prototype) || {};
53
107
  const softDeleteField = Reflect.getMetadata(SoftDeleteKey, model.prototype);
54
108
 
55
109
  const columnDefs = Object.entries(columns).map(([key, col]) => {
56
- let def: string;
57
110
  if (col.name === "id") {
58
- // Use correct PK syntax for each DB
59
- def = getAutoIncrementPK(dbType);
60
- return def;
111
+ return `id ${getAutoIncrementPK(dbType)}`;
61
112
  }
62
- def = `${col.name} ${col.type}`;
63
- if (validators[key]?.includes("required")) def += " NOT NULL";
64
- if (validators[key]?.includes("unique")) def += " UNIQUE";
65
- // Handle timestamps
66
- if (["createdAt", "updatedAt"].includes(col.name)) {
67
- def = `${col.name} ${getTimestampType(dbType)}`;
113
+
114
+ const defParts: string[] = [col.name];
115
+
116
+ if (["createdAt", "updatedAt"].includes(key) || (softDeleteField && key === softDeleteField)) {
117
+ defParts.push(getTimestampType(dbType));
118
+ } else {
119
+ defParts.push(col.type);
120
+ }
121
+
122
+ if (validators[key]?.includes("required")) {
123
+ defParts.push("NOT NULL");
124
+ }
125
+ if (validators[key]?.includes("unique")) {
126
+ defParts.push("UNIQUE");
127
+ }
128
+
129
+ if (key === "createdAt") {
130
+ defParts.push(getTimestampDefault(dbType));
68
131
  }
69
- return def;
132
+
133
+ return defParts.join(" ");
70
134
  });
71
135
 
72
- if (softDeleteField && columns[softDeleteField]) {
73
- columnDefs.push(
74
- `${columns[softDeleteField].name} ${columns[softDeleteField].type}`,
75
- );
136
+ if (softDeleteField && !columns[softDeleteField]) {
137
+ columnDefs.push(`${softDeleteField} ${getTimestampType(dbType)}`);
76
138
  }
77
139
 
78
- const up = [
79
- `CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`,
80
- ];
140
+ const up = [`CREATE TABLE IF NOT EXISTS ${tableName} (${columnDefs.join(", ")})`];
81
141
  const down = [`DROP TABLE IF EXISTS ${tableName}`];
82
142
 
83
- return { up, down };
143
+ return { up, down, name: tableName };
84
144
  }
85
145
 
86
- // Create migrations table with correct types for each DB
87
- function getMigrationsTableSQL(dbType: DBType) {
146
+ /**
147
+ * @internal
148
+ * Gets the database-specific SQL for creating the `migrations` table, which tracks applied migrations.
149
+ * @param dbType The target database dialect.
150
+ * @returns The SQL string for the `CREATE TABLE` statement.
151
+ */
152
+ function getMigrationsTableSQL(dbType: DBType): string {
88
153
  switch (dbType) {
89
154
  case DBType.Postgres:
90
155
  return `CREATE TABLE IF NOT EXISTS migrations (
91
156
  id SERIAL PRIMARY KEY,
92
- name TEXT NOT NULL,
93
- applied_at TIMESTAMP NOT NULL
157
+ name VARCHAR(255) UNIQUE NOT NULL,
158
+ applied_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
94
159
  )`;
95
160
  case DBType.MySQL:
96
161
  return `CREATE TABLE IF NOT EXISTS migrations (
97
162
  id INT AUTO_INCREMENT PRIMARY KEY,
98
- name VARCHAR(255) NOT NULL,
99
- applied_at DATETIME NOT NULL
163
+ name VARCHAR(255) UNIQUE NOT NULL,
164
+ applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
100
165
  )`;
101
166
  case DBType.SQLite:
167
+ default:
102
168
  return `CREATE TABLE IF NOT EXISTS migrations (
103
169
  id INTEGER PRIMARY KEY AUTOINCREMENT,
104
- name TEXT NOT NULL,
105
- applied_at TEXT NOT NULL
106
- )`;
107
- default: // Default to Postgres
108
- return `CREATE TABLE IF NOT EXISTS migrations (
109
- id SERIAL PRIMARY KEY,
110
- name TEXT NOT NULL,
111
- applied_at TIMESTAMP NOT NULL
170
+ name TEXT UNIQUE NOT NULL,
171
+ applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
112
172
  )`;
113
173
  }
114
174
  }
115
175
 
176
+ /**
177
+ * Connects to the database and runs all pending migrations.
178
+ * It tracks which migrations have been applied by using a `migrations` table in the database.
179
+ * Each migration is run within a transaction to ensure atomicity.
180
+ *
181
+ * @param config The database configuration object.
182
+ * @param migrations An array of `Migration` objects to be executed.
183
+ * @example
184
+ * ```
185
+ * // In a script like 'scripts/run_all_migrations.ts'
186
+ * import { runMigrations } from 'stabilize-orm';
187
+ * import { dbConfig } from './config';
188
+ * import migration1 from '../migrations/1_create_users.json';
189
+ * import migration2 from '../migrations.ts/2_create_profiles.json';
190
+ *
191
+ * const allMigrations = [migration1, migration2];
192
+ *
193
+ * async function applyMigrations() {
194
+ * console.log('Starting migration process...');
195
+ * await runMigrations(dbConfig, allMigrations);
196
+ * console.log('All pending migrations applied successfully.');
197
+ * }
198
+ *
199
+ * applyMigrations();
200
+ * ```
201
+ */
116
202
  export async function runMigrations(config: DBConfig, migrations: Migration[]) {
117
203
  const client = new DBClient(config);
118
204
  try {
119
- // Detect DB type from config
120
- const dbType = config.type ?? DBType.Postgres;
121
-
205
+ const dbType = config.type;
122
206
  await client.query(getMigrationsTableSQL(dbType));
123
207
 
124
208
  for (const [index, migration] of migrations.entries()) {
125
- const name = `migration_${index}_${new Date().toISOString().replace(/[-:T.]/g, "")}`;
209
+ const name = migration.name || `migration_${index}_${new Date().getTime()}`;
210
+
126
211
  const applied = await client.query<{ id: number }>(
127
212
  `SELECT id FROM migrations WHERE name = ?`,
128
213
  [name],
129
214
  );
130
215
 
131
216
  if (applied.length === 0) {
132
- for (const query of migration.up) {
133
- await client.query(query, []);
134
- }
135
- await client.query(
136
- `INSERT INTO migrations (name, applied_at) VALUES (?, ?)`,
137
- [name, new Date().toISOString()],
138
- );
217
+ await client.transaction(async (txClient) => {
218
+ console.log(`Applying migration: ${name}...`);
219
+ for (const query of migration.up) {
220
+ await txClient.query(query);
221
+ }
222
+ await txClient.query(
223
+ `INSERT INTO migrations (name, applied_at) VALUES (?, ?)`,
224
+ [name, new Date().toISOString()],
225
+ );
226
+ console.log(`Migration ${name} applied successfully.`);
227
+ });
139
228
  }
140
229
  }
230
+ } catch (error) {
231
+ console.error("Migration failed:", error);
232
+ throw error;
141
233
  } finally {
142
234
  await client.close();
143
235
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "stabilize-orm",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
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",
7
7
  "bin": {
8
- "stabilize": "./dist/cli/stabilize-cli.js"
8
+ "stabilize": "dist/cli/stabilize-cli.js"
9
9
  },
10
10
  "scripts": {
11
11
  "build": "bun build src/index.ts --outdir dist --target bun --minify && bun build cli/stabilize-cli.ts --outdir dist/cli --target bun --minify",
@@ -28,11 +28,14 @@
28
28
  "author": "ElectronSz <lwazicd@icloud.com>",
29
29
  "license": "MIT",
30
30
  "dependencies": {
31
+ "@types/pg": "^8.15.5",
31
32
  "@types/uuid": "^11.0.0",
32
33
  "commander": "^12.1.0",
33
34
  "figlet": "^1.9.3",
34
35
  "glob": "^11.0.0",
35
36
  "ioredis": "^5.4.1",
37
+ "mysql2": "^3.15.2",
38
+ "pg": "^8.16.3",
36
39
  "reflect-metadata": "^0.2.2",
37
40
  "uuid": "^13.0.0"
38
41
  },
package/query-builder.ts CHANGED
@@ -1,7 +1,18 @@
1
+ /**
2
+ * @file query-builder.ts
3
+ * @description Provides a fluent API for building and executing SQL queries in a database-agnostic way.
4
+ * @author ElectronSz
5
+ */
6
+
1
7
  import { DBClient } from "./client";
2
8
  import { Cache } from "./cache";
3
- import { type QueryHint } from "./types";
4
9
 
10
+ /**
11
+ * A fluent interface for building SQL SELECT queries.
12
+ * This class allows for the programmatic and readable construction of queries
13
+ * that can be executed on different database systems via the DBClient.
14
+ * @template T The type of the entity being queried.
15
+ */
5
16
  export class QueryBuilder<T> {
6
17
  private table: string;
7
18
  private selectFields: string[] = ["*"];
@@ -11,54 +22,110 @@ export class QueryBuilder<T> {
11
22
  private orderByClause: string | null = null;
12
23
  private limitValue: number | null = null;
13
24
  private offsetValue: number | null = null;
14
- private hints: QueryHint[] = [];
15
25
 
26
+ /**
27
+ * Creates an instance of QueryBuilder.
28
+ * @param table The name of the main table to query from.
29
+ */
16
30
  constructor(table: string) {
17
31
  this.table = table;
18
32
  }
19
33
 
34
+ /**
35
+ * Specifies the columns to select. If not called, all columns (`*`) are selected by default.
36
+ * @param fields A list of column names to select.
37
+ * @returns The `QueryBuilder` instance for chaining.
38
+ * @example
39
+ * ```
40
+ * queryBuilder.select('id', 'name', 'email');
41
+ * ```
42
+ */
20
43
  select(...fields: string[]): QueryBuilder<T> {
21
44
  this.selectFields = fields.length > 0 ? fields : ["*"];
22
45
  return this;
23
46
  }
24
47
 
48
+ /**
49
+ * Adds a WHERE clause to the query. Multiple calls will be joined with AND.
50
+ * @param condition The SQL condition string with `?` as placeholders.
51
+ * @param params The values to substitute for the `?` placeholders.
52
+ * @returns The `QueryBuilder` instance for chaining.
53
+ * @example
54
+ * ```
55
+ * queryBuilder.where('status = ?', 'active').where('age > ?', 21);
56
+ * ```
57
+ */
25
58
  where(condition: string, ...params: any[]): QueryBuilder<T> {
26
59
  this.whereConditions.push(condition);
27
60
  this.whereParams.push(...params);
28
61
  return this;
29
62
  }
30
63
 
64
+ /**
65
+ * Adds a LEFT JOIN clause to the query.
66
+ * @param table The name of the table to join with.
67
+ * @param condition The ON condition for the join.
68
+ * @returns The `QueryBuilder` instance for chaining.
69
+ * @example
70
+ * ```
71
+ * queryBuilder.join('profiles', 'profiles.userId = users.id');
72
+ * ```
73
+ */
31
74
  join(table: string, condition: string): QueryBuilder<T> {
32
75
  this.joins.push(`LEFT JOIN ${table} ON ${condition}`);
33
76
  return this;
34
77
  }
35
78
 
79
+ /**
80
+ * Adds an ORDER BY clause to the query.
81
+ * @param clause The column and direction for ordering (e.g., 'createdAt DESC').
82
+ * @returns The `QueryBuilder` instance for chaining.
83
+ * @example
84
+ * ```
85
+ * queryBuilder.orderBy('lastName ASC');
86
+ * ```
87
+ */
36
88
  orderBy(clause: string): QueryBuilder<T> {
37
89
  this.orderByClause = clause;
38
90
  return this;
39
91
  }
40
92
 
93
+ /**
94
+ * Adds a LIMIT clause to the query to restrict the number of rows returned.
95
+ * @param limit The maximum number of rows to return.
96
+ * @returns The `QueryBuilder` instance for chaining.
97
+ * @example
98
+ * ```
99
+ * queryBuilder.limit(10);
100
+ * ```
101
+ */
41
102
  limit(limit: number): QueryBuilder<T> {
42
103
  this.limitValue = limit;
43
104
  return this;
44
105
  }
45
106
 
107
+ /**
108
+ * Adds an OFFSET clause to the query for pagination.
109
+ * @param offset The number of rows to skip.
110
+ * @returns The `QueryBuilder` instance for chaining.
111
+ * @example
112
+ * ```
113
+ * queryBuilder.offset(20);
114
+ * ```
115
+ */
46
116
  offset(offset: number): QueryBuilder<T> {
47
117
  this.offsetValue = offset;
48
118
  return this;
49
119
  }
50
120
 
51
- hint(hint: QueryHint): QueryBuilder<T> {
52
- this.hints.push(hint);
53
- return this;
54
- }
55
-
121
+ /**
122
+ * Constructs the final SQL query string and its corresponding parameters.
123
+ * This is an internal method, typically called by `execute`.
124
+ * @returns An object containing the final `query` string and `params` array.
125
+ */
56
126
  build(): { query: string; params: any[] } {
57
127
  let query = `SELECT ${this.selectFields.join(", ")} FROM ${this.table}`;
58
- if (this.hints.length > 0) {
59
- const hintStr = this.hints.map((h) => `${h.type}(${h.value})`).join(" ");
60
- query = `SELECT ${hintStr} ${this.selectFields.join(", ")} FROM ${this.table}`;
61
- }
128
+
62
129
  if (this.joins.length > 0) {
63
130
  query += " " + this.joins.join(" ");
64
131
  }
@@ -77,20 +144,43 @@ export class QueryBuilder<T> {
77
144
  return { query, params: this.whereParams };
78
145
  }
79
146
 
147
+ /**
148
+ * Executes the constructed query against the database using the provided client.
149
+ * Handles cache-aside logic if a cache and cacheKey are provided.
150
+ * @param client The `DBClient` instance to use for executing the query.
151
+ * @param cache Optional: The `Cache` instance to use for caching.
152
+ * @param cacheKey Optional: The key to use for getting/setting the result in the cache.
153
+ * @returns A promise that resolves to an array of results of type `T`.
154
+ * @example
155
+ * ```
156
+ * const users = await stabilize.getRepository(User)
157
+ * .find()
158
+ * .where('status = ?', 'active')
159
+ * .limit(10)
160
+ * .execute(dbClient, cache, 'active_users_page_1');
161
+ * ```
162
+ */
80
163
  async execute(
81
164
  client: DBClient,
82
165
  cache?: Cache,
83
166
  cacheKey?: string,
84
167
  ): Promise<T[]> {
85
168
  const { query, params } = this.build();
169
+
170
+ // Attempt to retrieve from cache first (cache-aside read)
86
171
  if (cache && cacheKey) {
87
172
  const cached = await cache.get<T[]>(cacheKey);
88
173
  if (cached) return cached;
89
174
  }
175
+
176
+ // If not in cache, execute query against the database
90
177
  const results = await client.query<T>(query, params);
178
+
179
+ // Store the database results in the cache for future requests
91
180
  if (cache && cacheKey && results.length > 0) {
92
- await cache.set(cacheKey, results);
181
+ await cache.set(cacheKey, results, 60);
93
182
  }
183
+
94
184
  return results;
95
185
  }
96
- }
186
+ }