forge-sql-orm 1.0.30 → 1.1.31

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.
Files changed (37) hide show
  1. package/README.md +242 -661
  2. package/dist/ForgeSQLORM.js +541 -568
  3. package/dist/ForgeSQLORM.js.map +1 -1
  4. package/dist/ForgeSQLORM.mjs +539 -555
  5. package/dist/ForgeSQLORM.mjs.map +1 -1
  6. package/dist/core/ForgeSQLCrudOperations.d.ts +101 -130
  7. package/dist/core/ForgeSQLCrudOperations.d.ts.map +1 -1
  8. package/dist/core/ForgeSQLORM.d.ts +11 -10
  9. package/dist/core/ForgeSQLORM.d.ts.map +1 -1
  10. package/dist/core/ForgeSQLQueryBuilder.d.ts +271 -113
  11. package/dist/core/ForgeSQLQueryBuilder.d.ts.map +1 -1
  12. package/dist/core/ForgeSQLSelectOperations.d.ts +65 -22
  13. package/dist/core/ForgeSQLSelectOperations.d.ts.map +1 -1
  14. package/dist/core/SystemTables.d.ts +59 -0
  15. package/dist/core/SystemTables.d.ts.map +1 -0
  16. package/dist/index.d.ts +1 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/utils/sqlUtils.d.ts +53 -6
  19. package/dist/utils/sqlUtils.d.ts.map +1 -1
  20. package/dist-cli/cli.js +561 -360
  21. package/dist-cli/cli.js.map +1 -1
  22. package/dist-cli/cli.mjs +561 -360
  23. package/dist-cli/cli.mjs.map +1 -1
  24. package/package.json +26 -26
  25. package/src/core/ForgeSQLCrudOperations.ts +360 -473
  26. package/src/core/ForgeSQLORM.ts +40 -78
  27. package/src/core/ForgeSQLQueryBuilder.ts +250 -133
  28. package/src/core/ForgeSQLSelectOperations.ts +182 -72
  29. package/src/core/SystemTables.ts +7 -0
  30. package/src/index.ts +1 -2
  31. package/src/utils/sqlUtils.ts +155 -23
  32. package/dist/core/ComplexQuerySchemaBuilder.d.ts +0 -38
  33. package/dist/core/ComplexQuerySchemaBuilder.d.ts.map +0 -1
  34. package/dist/knex/index.d.ts +0 -4
  35. package/dist/knex/index.d.ts.map +0 -1
  36. package/src/core/ComplexQuerySchemaBuilder.ts +0 -63
  37. package/src/knex/index.ts +0 -4
@@ -1,564 +1,451 @@
1
- import { sql, UpdateQueryResponse } from "@forge/sql";
2
- import { EntityProperty, EntitySchema, ForgeSqlOrmOptions } from "..";
3
- import type { types } from "@mikro-orm/core/types";
4
- import { transformValue } from "../utils/sqlUtils";
1
+ import { ForgeSqlOrmOptions } from "..";
5
2
  import { CRUDForgeSQL, ForgeSqlOperation } from "./ForgeSQLQueryBuilder";
6
- import { EntityKey, QBFilterQuery } from "..";
7
- import Knex from "../knex";
8
-
3
+ import { AnyMySqlTable } from "drizzle-orm/mysql-core/index";
4
+ import { AnyColumn, InferInsertModel } from "drizzle-orm";
5
+ import { eq, and } from "drizzle-orm";
6
+ import { SQL } from "drizzle-orm";
7
+ import { getPrimaryKeys, getTableMetadata } from "../utils/sqlUtils";
8
+
9
+ /**
10
+ * Class implementing CRUD operations for ForgeSQL ORM.
11
+ * Provides methods for inserting, updating, and deleting records with support for optimistic locking.
12
+ */
9
13
  export class ForgeSQLCrudOperations implements CRUDForgeSQL {
10
14
  private readonly forgeOperations: ForgeSqlOperation;
11
15
  private readonly options: ForgeSqlOrmOptions;
12
16
 
17
+ /**
18
+ * Creates a new instance of ForgeSQLCrudOperations.
19
+ * @param forgeSqlOperations - The ForgeSQL operations instance
20
+ * @param options - Configuration options for the ORM
21
+ */
13
22
  constructor(forgeSqlOperations: ForgeSqlOperation, options: ForgeSqlOrmOptions) {
14
23
  this.forgeOperations = forgeSqlOperations;
15
24
  this.options = options;
16
25
  }
17
26
 
18
27
  /**
19
- * Generates an SQL INSERT statement for the provided models.
20
- * If a version field exists in the schema, its value is set accordingly.
28
+ * Inserts records into the database with optional versioning support.
29
+ * If a version field exists in the schema, versioning is applied.
21
30
  *
22
- * @param schema - The entity schema.
23
- * @param models - The list of entities to insert.
24
- * @param updateIfExists - Whether to update the row if it already exists.
25
- * @returns An object containing the SQL query, column names, and values.
31
+ * @template T - The type of the table schema
32
+ * @param {T} schema - The entity schema
33
+ * @param {Partial<InferInsertModel<T>>[]} models - Array of entities to insert
34
+ * @param {boolean} [updateIfExists=false] - Whether to update existing records
35
+ * @returns {Promise<number>} The number of inserted rows
36
+ * @throws {Error} If the insert operation fails
26
37
  */
27
- private async generateInsertScript<T extends object>(
28
- schema: EntitySchema<T>,
29
- models: T[],
30
- updateIfExists: boolean,
31
- ): Promise<{
32
- sql: string;
33
- query: string;
34
- fields: string[];
35
- values: { type: keyof typeof types; value: unknown }[];
36
- }> {
37
- const columnNames = new Set<string>();
38
- const modelFieldValues: Record<string, { type: keyof typeof types; value: unknown }>[] = [];
39
-
40
- // Build field values for each model.
41
- models.forEach((model) => {
42
- const fieldValues: Record<string, { type: keyof typeof types; value: unknown }> = {};
43
- schema.meta.props.forEach((prop) => {
44
- const value = model[prop.name];
45
- if (prop.kind === "scalar" && value !== undefined) {
46
- const columnName = this.getRealFieldNameFromSchema(prop);
47
- columnNames.add(columnName);
48
- fieldValues[columnName] = { type: prop.type as keyof typeof types, value };
49
- }
50
- });
51
- modelFieldValues.push(fieldValues);
52
- });
53
-
54
- // If a version field exists, set or update its value.
55
- const versionField = this.getVersionField(schema);
56
- if (versionField) {
57
- modelFieldValues.forEach((mv) => {
58
- const versionRealName = this.getRealFieldNameFromSchema(versionField);
59
- if (mv[versionRealName]) {
60
- mv[versionRealName].value = transformValue(
61
- { value: this.createVersionField(versionField), type: versionField.name },
62
- true,
63
- );
64
- } else {
65
- mv[versionRealName] = {
66
- type: versionField.type as keyof typeof types,
67
- value: transformValue(
68
- { value: this.createVersionField(versionField), type: versionField.name },
69
- true,
70
- ),
71
- };
72
- columnNames.add(versionField.name);
73
- }
74
- });
75
- }
38
+ async insert<T extends AnyMySqlTable>(
39
+ schema: T,
40
+ models: Partial<InferInsertModel<T>>[],
41
+ updateIfExists: boolean = false,
42
+ ): Promise<number> {
43
+ if (!models?.length) return 0;
76
44
 
77
- const columns = Array.from(columnNames);
78
-
79
- // Flatten values for each row in the order of columns.
80
- const values = modelFieldValues.flatMap((fieldValueMap) =>
81
- columns.map(
82
- (column) =>
83
- fieldValueMap[column] || {
84
- type: "string",
85
- value: null,
86
- },
87
- ),
45
+ const { tableName, columns } = getTableMetadata(schema);
46
+ const versionMetadata = this.validateVersionField(tableName, columns);
47
+
48
+ // Prepare models with version field if needed
49
+ const preparedModels = models.map((model) =>
50
+ this.prepareModelWithVersion(model, versionMetadata, columns),
88
51
  );
89
52
 
90
- // Build the VALUES clause.
91
- const insertValues = modelFieldValues
92
- .map((fieldValueMap) => {
93
- const rowValues = columns
94
- .map((column) =>
95
- transformValue(
96
- fieldValueMap[column] || { type: "string", value: null },
97
- true,
98
- ),
99
- )
100
- .join(",");
101
- return `(${rowValues})`;
102
- })
103
- .join(", ");
104
- // Build the VALUES ? clause.
105
- const insertEmptyValues = modelFieldValues
106
- .map(() => {
107
- const rowValues = columns
108
- .map(() =>
109
- '?',
110
- )
111
- .join(",");
112
- return `(${rowValues})`;
53
+ // Build insert query
54
+ const queryBuilder = this.forgeOperations
55
+ .getDrizzleQueryBuilder()
56
+ .insert(schema)
57
+ .values(preparedModels);
58
+
59
+ // Add onDuplicateKeyUpdate if needed
60
+ const finalQuery = updateIfExists
61
+ ? queryBuilder.onDuplicateKeyUpdate({
62
+ set: Object.fromEntries(
63
+ Object.keys(preparedModels[0]).map((key) => [key, (schema as any)[key]]),
64
+ ) as any,
113
65
  })
114
- .join(", ");
115
-
116
- const updateClause = updateIfExists
117
- ? ` ON DUPLICATE KEY UPDATE ${columns.map((col) => `${col} = VALUES(${col})`).join(",")}`
118
- : "";
119
-
120
- return {
121
- sql: `INSERT INTO ${schema.meta.collection} (${columns.join(",")}) VALUES ${insertValues}${updateClause}`,
122
- query: `INSERT INTO ${schema.meta.collection} (${columns.join(",")}) VALUES ${insertEmptyValues}${updateClause}`,
123
- fields: columns,
124
- values,
125
- };
126
- }
127
-
128
- /**
129
- * Inserts records into the database.
130
- * If a version field exists in the schema, versioning is applied.
131
- *
132
- * @param schema - The entity schema.
133
- * @param models - The list of entities to insert.
134
- * @param updateIfExists - Whether to update the row if it already exists.
135
- * @returns The ID of the inserted row.
136
- */
137
- async insert<T extends object>(
138
- schema: EntitySchema<T>,
139
- models: T[],
140
- updateIfExists: boolean = false,
141
- ): Promise<number> {
142
- if (!models || models.length === 0) return 0;
66
+ : queryBuilder;
143
67
 
144
- const query = await this.generateInsertScript(schema, models, updateIfExists);
68
+ // Execute query
69
+ const query = finalQuery.toSQL();
145
70
  if (this.options?.logRawSqlQuery) {
146
- console.debug("INSERT SQL: " + query.query);
71
+ console.debug("INSERT SQL:", query.sql);
147
72
  }
148
- const sqlStatement = sql.prepare<UpdateQueryResponse>(query.sql);
149
- const result = await sqlStatement.execute();
150
- return result.rows.insertId;
151
- }
152
73
 
153
- /**
154
- * Retrieves the primary key properties from the entity schema.
155
- *
156
- * @param schema - The entity schema.
157
- * @returns An array of primary key properties.
158
- * @throws If no primary keys are found.
159
- */
160
- private getPrimaryKeys<T extends object>(schema: EntitySchema<T>): EntityProperty<T, unknown>[] {
161
- const primaryKeys = schema.meta.props.filter((prop) => prop.primary);
162
- if (!primaryKeys.length) {
163
- throw new Error(`No primary keys found for schema: ${schema.meta.className}`);
164
- }
165
- return primaryKeys;
74
+ const result = await this.forgeOperations.fetch().executeRawUpdateSQL(query.sql, query.params);
75
+
76
+ return result.insertId;
166
77
  }
167
78
 
168
79
  /**
169
- * Deletes a record by its primary key.
80
+ * Deletes a record by its primary key with optional version check.
81
+ * If versioning is enabled, ensures the record hasn't been modified since last read.
170
82
  *
171
- * @param id - The ID of the record to delete.
172
- * @param schema - The entity schema.
173
- * @returns The number of rows affected.
174
- * @throws If the entity has more than one primary key.
83
+ * @template T - The type of the table schema
84
+ * @param {unknown} id - The ID of the record to delete
85
+ * @param {T} schema - The entity schema
86
+ * @returns {Promise<number>} Number of affected rows
87
+ * @throws {Error} If the delete operation fails
88
+ * @throws {Error} If multiple primary keys are found
175
89
  */
176
- async deleteById<T extends object>(id: unknown, schema: EntitySchema<T>): Promise<number> {
90
+ async deleteById<T extends AnyMySqlTable>(id: unknown, schema: T): Promise<number> {
91
+ const { tableName, columns } = getTableMetadata(schema);
177
92
  const primaryKeys = this.getPrimaryKeys(schema);
178
- if (primaryKeys.length > 1) {
179
- throw new Error("Only one primary key is supported");
93
+
94
+ if (primaryKeys.length !== 1) {
95
+ throw new Error("Only single primary key is supported");
180
96
  }
181
97
 
182
- const primaryKey = primaryKeys[0];
183
- const queryBuilder = this.forgeOperations.createQueryBuilder(schema.meta.class).delete();
184
- queryBuilder.andWhere({ [primaryKey.name]: { $eq: id } });
98
+ const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
99
+ const versionMetadata = this.validateVersionField(tableName, columns);
100
+
101
+ // Build delete conditions
102
+ const conditions: SQL<unknown>[] = [eq(primaryKeyColumn, id)];
103
+
104
+ // Add version check if needed
105
+ if (versionMetadata && columns) {
106
+ const versionField = columns[versionMetadata.fieldName];
107
+ if (versionField) {
108
+ const oldModel = await this.getOldModel({ [primaryKeyName]: id }, schema, [
109
+ versionMetadata.fieldName,
110
+ versionField,
111
+ ]);
112
+ conditions.push(eq(versionField, (oldModel as any)[versionMetadata.fieldName]));
113
+ }
114
+ }
115
+
116
+ // Execute delete query
117
+ const queryBuilder = this.forgeOperations
118
+ .getDrizzleQueryBuilder()
119
+ .delete(schema)
120
+ .where(and(...conditions));
185
121
 
186
- const query = queryBuilder.getFormattedQuery();
187
122
  if (this.options?.logRawSqlQuery) {
188
- console.debug("DELETE SQL: " + queryBuilder.getQuery());
123
+ console.debug("DELETE SQL:", queryBuilder.toSQL().sql);
189
124
  }
190
- const sqlStatement = sql.prepare<UpdateQueryResponse>(query);
191
- const result = await sqlStatement.execute();
192
- return result.rows.affectedRows;
125
+
126
+ const result = await this.forgeOperations
127
+ .fetch()
128
+ .executeRawUpdateSQL(queryBuilder.toSQL().sql, queryBuilder.toSQL().params);
129
+
130
+ return result.affectedRows;
193
131
  }
194
132
 
195
133
  /**
196
- * Retrieves the version field from the entity schema.
197
- * The version field must be of type datetime, integer, or decimal, not a primary key, and not nullable.
134
+ * Updates a record by its primary key with optimistic locking support.
135
+ * If versioning is enabled:
136
+ * - Retrieves the current version
137
+ * - Checks for concurrent modifications
138
+ * - Increments the version on successful update
198
139
  *
199
- * @param schema - The entity schema.
200
- * @returns The version field property if it exists.
140
+ * @template T - The type of the table schema
141
+ * @param {Partial<InferInsertModel<T>>} entity - The entity with updated values
142
+ * @param {T} schema - The entity schema
143
+ * @returns {Promise<number>} Number of affected rows
144
+ * @throws {Error} If the primary key is not provided
145
+ * @throws {Error} If optimistic locking check fails
146
+ * @throws {Error} If multiple primary keys are found
201
147
  */
202
- getVersionField<T>(schema: EntitySchema<T>) {
203
- if (this.options.disableOptimisticLocking){
204
- return undefined;
148
+ async updateById<T extends AnyMySqlTable>(
149
+ entity: Partial<InferInsertModel<T>>,
150
+ schema: T,
151
+ ): Promise<number> {
152
+ const { tableName, columns } = getTableMetadata(schema);
153
+ const primaryKeys = this.getPrimaryKeys(schema);
154
+
155
+ if (primaryKeys.length !== 1) {
156
+ throw new Error("Only single primary key is supported");
205
157
  }
206
- return schema.meta.props
207
- .filter((prop) => prop.version)
208
- .filter((prop) => {
209
- const validType =
210
- prop.type === "datetime" || prop.type === "integer" || prop.type === "decimal";
211
- if (!validType) {
212
- console.warn(
213
- `Version field "${prop.name}" in table ${schema.meta.tableName} must be datetime, integer, or decimal, but is "${prop.type}"`,
214
- );
215
- }
216
- return validType;
217
- })
218
- .filter((prop) => {
219
- if (prop.primary) {
220
- console.warn(
221
- `Version field "${prop.name}" in table ${schema.meta.tableName} cannot be a primary key`,
222
- );
223
- return false;
224
- }
225
- return true;
226
- })
227
- .find((prop) => {
228
- if (prop.nullable) {
229
- console.warn(
230
- `Version field "${prop.name}" in table ${schema.meta.tableName} should not be nullable`,
231
- );
232
- return false;
233
- }
234
- return true;
235
- });
236
- }
237
158
 
238
- /**
239
- * Increments the version field of an entity.
240
- * For datetime types, sets the current date; for numeric types, increments by 1.
241
- *
242
- * @param versionField - The version field property.
243
- * @param updateModel - The entity to update.
244
- */
245
- incrementVersionField<T>(versionField: EntityProperty<T, any>, updateModel: T): void {
246
- const key = versionField.name as keyof T;
247
- switch (versionField.type) {
248
- case "datetime": {
249
- updateModel[key] = new Date() as unknown as T[keyof T];
250
- break;
251
- }
252
- case "decimal":
253
- case "integer": {
254
- updateModel[key] = ((updateModel[key] as number) + 1) as unknown as T[keyof T];
255
- break;
256
- }
257
- default:
258
- throw new Error(`Unsupported version field type: ${versionField.type}`);
159
+ const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
160
+ const versionMetadata = this.validateVersionField(tableName, columns);
161
+
162
+ // Validate primary key
163
+ if (!(primaryKeyName in entity)) {
164
+ throw new Error(`Primary key ${primaryKeyName} must be provided in the entity`);
259
165
  }
260
- }
261
166
 
262
- /**
263
- * Creates the initial version field value for an entity.
264
- * For datetime types, returns the current date; for numeric types, returns 0.
265
- *
266
- * @param versionField - The version field property.
267
- */
268
- createVersionField<T>(versionField: EntityProperty<T>): unknown {
269
- switch (versionField.type) {
270
- case "datetime": {
271
- return new Date() as unknown as T[keyof T];
272
- }
273
- case "decimal":
274
- case "integer": {
275
- return 0;
167
+ // Get current version if needed
168
+ const currentVersion = await this.getCurrentVersion(
169
+ entity,
170
+ primaryKeyName,
171
+ versionMetadata,
172
+ columns,
173
+ schema,
174
+ );
175
+
176
+ // Prepare update data with version
177
+ const updateData = this.prepareUpdateData(entity, versionMetadata, columns, currentVersion);
178
+
179
+ // Build update conditions
180
+ const conditions: SQL<unknown>[] = [
181
+ eq(primaryKeyColumn, entity[primaryKeyName as keyof typeof entity]),
182
+ ];
183
+ if (versionMetadata && columns) {
184
+ const versionField = columns[versionMetadata.fieldName];
185
+ if (versionField) {
186
+ conditions.push(eq(versionField, currentVersion));
276
187
  }
277
- default:
278
- throw new Error(`Unsupported version field type: ${versionField.type}`);
279
188
  }
280
- }
281
189
 
282
- /**
283
- * Updates a record by its primary key using the provided entity data.
284
- *
285
- * @param entity - The entity with updated values.
286
- * @param schema - The entity schema.
287
- */
288
- async updateById<T extends object>(entity: Partial<T>, schema: EntitySchema<T>): Promise<void> {
289
- const fields = schema.meta.props
290
- .filter((prop) => prop.kind === "scalar")
291
- .map((prop) => prop.name);
292
- await this.updateFieldById(entity as T, fields, schema);
190
+ // Execute update query
191
+ const queryBuilder = this.forgeOperations
192
+ .getDrizzleQueryBuilder()
193
+ .update(schema)
194
+ .set(updateData)
195
+ .where(and(...conditions));
196
+
197
+ if (this.options?.logRawSqlQuery) {
198
+ console.debug("UPDATE SQL:", queryBuilder.toSQL().sql);
199
+ }
200
+
201
+ const result = await this.forgeOperations
202
+ .fetch()
203
+ .executeRawUpdateSQL(queryBuilder.toSQL().sql, queryBuilder.toSQL().params);
204
+
205
+ // Check optimistic locking
206
+ if (versionMetadata && result.affectedRows === 0) {
207
+ throw new Error(
208
+ `Optimistic locking failed: record with primary key ${entity[primaryKeyName as keyof typeof entity]} has been modified`,
209
+ );
210
+ }
211
+
212
+ return result.affectedRows;
293
213
  }
294
214
 
295
215
  /**
296
216
  * Updates specified fields of records based on provided conditions.
297
- * If the "where" parameter is not provided, the WHERE clause is built from the entity fields
298
- * that are not included in the list of fields to update.
217
+ * This method does not support versioning and should be used with caution.
299
218
  *
300
- * @param entity - The object containing values to update and potential criteria for filtering.
301
- * @param fields - Array of field names to update.
302
- * @param schema - The entity schema.
303
- * @param where - Optional filtering conditions for the WHERE clause.
304
- * @returns The number of affected rows.
305
- * @throws If no filtering criteria are provided (either via "where" or from the remaining entity fields).
219
+ * @template T - The type of the table schema
220
+ * @param {Partial<InferInsertModel<T>>} updateData - The data to update
221
+ * @param {T} schema - The entity schema
222
+ * @param {SQL<unknown>} where - The WHERE conditions
223
+ * @returns {Promise<number>} Number of affected rows
224
+ * @throws {Error} If WHERE conditions are not provided
225
+ * @throws {Error} If the update operation fails
306
226
  */
307
- async updateFields<T extends object>(
308
- entity: Partial<T>,
309
- fields: EntityKey<T>[],
310
- schema: EntitySchema<T>,
311
- where?: QBFilterQuery<T>,
227
+ async updateFields<T extends AnyMySqlTable>(
228
+ updateData: Partial<InferInsertModel<T>>,
229
+ schema: T,
230
+ where?: SQL<unknown>,
312
231
  ): Promise<number> {
313
- // Extract update data from the entity based on the provided fields.
314
- const updateData = this.filterEntityFields(entity, fields);
315
- const updateModel = this.modifyModel(updateData as T, schema);
316
-
317
- // Create the query builder for the entity.
318
- let queryBuilder = this.forgeOperations
319
- .createQueryBuilder(schema.meta.class)
320
- .getKnexQuery();
321
-
322
- // Set the update data.
323
- queryBuilder.update(updateModel as T);
324
-
325
- // Use the provided "where" conditions if available; otherwise, build conditions from the remaining entity fields.
326
- if (where) {
327
- queryBuilder.where(where);
328
- } else {
329
- const filterCriteria = (Object.keys(entity) as Array<keyof T>)
330
- .filter((key: keyof T) => !fields.includes(key as EntityKey<T>))
331
- .reduce((criteria, key) => {
332
- if (entity[key] !== undefined) {
333
- // Cast key to string to use it as an object key.
334
- criteria[key as string] = entity[key];
335
- }
336
- return criteria;
337
- }, {} as Record<string, unknown>);
338
-
339
-
340
- if (Object.keys(filterCriteria).length === 0) {
341
- throw new Error(
342
- "Filtering criteria (WHERE clause) must be provided either via the 'where' parameter or through non-updated entity fields"
343
- );
344
- }
345
- queryBuilder.where(filterCriteria);
232
+ if (!where) {
233
+ throw new Error("WHERE conditions must be provided");
346
234
  }
347
235
 
236
+ const queryBuilder = this.forgeOperations
237
+ .getDrizzleQueryBuilder()
238
+ .update(schema)
239
+ .set(updateData)
240
+ .where(where);
241
+
348
242
  if (this.options?.logRawSqlQuery) {
349
- console.debug("UPDATE SQL (updateFields): " + queryBuilder.toSQL().sql);
243
+ console.debug("UPDATE SQL:", queryBuilder.toSQL().sql);
350
244
  }
351
245
 
352
- // Execute the update query.
353
- const sqlQuery = queryBuilder.toQuery();
354
- const updateQueryResponse = await this.forgeOperations.fetch().executeRawUpdateSQL(sqlQuery);
355
- return updateQueryResponse.affectedRows;
246
+ const result = await this.forgeOperations
247
+ .fetch()
248
+ .executeRawUpdateSQL(queryBuilder.toSQL().sql, queryBuilder.toSQL().params);
249
+
250
+ return result.affectedRows;
356
251
  }
357
252
 
253
+ // Helper methods
358
254
 
359
255
  /**
360
- * Updates specific fields of a record identified by its primary key.
361
- * If a version field exists in the schema, versioning is applied.
362
- *
363
- * @param entity - The entity with updated values.
364
- * @param fields - The list of field names to update.
365
- * @param schema - The entity schema.
366
- * @throws If the primary key is not included in the update fields.
256
+ * Gets primary keys from the schema.
257
+ * @template T - The type of the table schema
258
+ * @param {T} schema - The table schema
259
+ * @returns {[string, AnyColumn][]} Array of primary key name and column pairs
260
+ * @throws {Error} If no primary keys are found
367
261
  */
368
- async updateFieldById<T extends object>(
369
- entity: T,
370
- fields: EntityKey<T>[],
371
- schema: EntitySchema<T>,
372
- ): Promise<void> {
373
- const primaryKeys = this.getPrimaryKeys(schema);
374
- primaryKeys.forEach((pk) => {
375
- if (!fields.includes(pk.name)) {
376
- throw new Error("Update fields must include primary key: " + pk.name);
377
- }
378
- });
379
-
380
- // Prepare updated entity and query builder.
381
- const updatedEntity = this.filterEntityFields(entity, fields);
382
- let queryBuilder = this.forgeOperations.createQueryBuilder(schema.meta.class).getKnexQuery();
383
- const versionField = this.getVersionField(schema);
384
- const useVersion = Boolean(versionField);
385
- let updateModel = { ...updatedEntity };
386
-
387
- if (useVersion && versionField) {
388
- // If the version field is missing from the entity, load the old record.
389
- let oldModel = entity;
390
- if (entity[versionField.name] === undefined || entity[versionField.name] === null) {
391
- oldModel = await this.getOldModel(primaryKeys, entity, schema, versionField);
392
- }
393
- const primaryFieldNames = primaryKeys.map((pk) => pk.name);
394
- const fieldsToRetain = primaryFieldNames.concat(versionField.name);
395
- const fromEntries = Object.fromEntries(fieldsToRetain.map((key) => [key, oldModel[key]]));
396
- updateModel = { ...updatedEntity, ...fromEntries };
397
-
398
- // Increment the version field.
399
- this.incrementVersionField(versionField, updateModel as T);
400
-
401
- updateModel = this.modifyModel(updateModel as T, schema);
402
- queryBuilder.update(updateModel as T);
403
- if (oldModel[versionField.name]!==undefined || oldModel[versionField.name]!==null && this.isValid(oldModel[versionField.name])) {
404
- queryBuilder.andWhere(this.optWhere(oldModel, versionField));
405
- }
406
- } else {
407
- updateModel = this.modifyModel(updatedEntity as T, schema);
408
- queryBuilder.update(updateModel as T);
262
+ private getPrimaryKeys<T extends AnyMySqlTable>(schema: T): [string, AnyColumn][] {
263
+ const primaryKeys = getPrimaryKeys(schema);
264
+ if (!primaryKeys) {
265
+ throw new Error(`No primary keys found for schema: ${schema}`);
409
266
  }
410
267
 
411
- this.addPrimaryWhere(queryBuilder as unknown as Knex.QueryBuilder<any, any>, primaryKeys, updateModel as T);
412
- const sqlQuery = queryBuilder.toQuery();
268
+ return primaryKeys;
269
+ }
270
+
271
+ /**
272
+ * Validates and retrieves version field metadata.
273
+ * @param {string} tableName - The name of the table
274
+ * @param {Record<string, AnyColumn>} columns - The table columns
275
+ * @returns {Object | undefined} Version field metadata if valid, undefined otherwise
276
+ */
277
+ private validateVersionField(
278
+ tableName: string,
279
+ columns: Record<string, AnyColumn>,
280
+ ): { fieldName: string; type: string } | undefined {
281
+ if (this.options.disableOptimisticLocking) {
282
+ return undefined;
283
+ }
284
+ const versionMetadata = this.options.additionalMetadata?.[tableName]?.versionField;
285
+ if (!versionMetadata) return undefined;
413
286
 
414
- if (this.options?.logRawSqlQuery) {
415
- console.debug("UPDATE SQL: " + queryBuilder.toSQL().sql);
287
+ const versionField = columns[versionMetadata.fieldName];
288
+ if (!versionField) {
289
+ console.warn(
290
+ `Version field "${versionMetadata.fieldName}" not found in table ${tableName}. Versioning will be skipped.`,
291
+ );
292
+ return undefined;
416
293
  }
417
- const updateQueryResponse = await this.forgeOperations.fetch().executeRawUpdateSQL(sqlQuery);
418
- if (versionField && !updateQueryResponse.affectedRows) {
419
- throw new Error(
420
- "Optimistic locking failed: the record with primary key(s) " +
421
- primaryKeys.map((p) => updateModel[p.name]).join(", ") +
422
- " has been modified by another process.",
294
+
295
+ if (!versionField.notNull) {
296
+ console.warn(
297
+ `Version field "${versionMetadata.fieldName}" in table ${tableName} is nullable. Versioning may not work correctly.`,
423
298
  );
299
+ return undefined;
424
300
  }
301
+
302
+ const fieldType = versionField.getSQLType();
303
+ const isSupportedType =
304
+ fieldType === "datetime" ||
305
+ fieldType === "timestamp" ||
306
+ fieldType === "int" ||
307
+ fieldType === "number" ||
308
+ fieldType === "decimal";
309
+
310
+ if (!isSupportedType) {
311
+ console.warn(
312
+ `Version field "${versionMetadata.fieldName}" in table ${tableName} has unsupported type "${fieldType}". ` +
313
+ `Only datetime, timestamp, int, and decimal types are supported for versioning. Versioning will be skipped.`,
314
+ );
315
+ return undefined;
316
+ }
317
+
318
+ return { fieldName: versionMetadata.fieldName, type: fieldType };
425
319
  }
426
320
 
427
321
  /**
428
- * Constructs an optional WHERE clause for the version field.
429
- *
430
- * @param updateModel - The model containing the current version field value.
431
- * @param versionField - The version field property.
432
- * @returns A filter query for the version field.
322
+ * Gets the current version of an entity.
323
+ * @template T - The type of the table schema
324
+ * @param {Partial<InferInsertModel<T>>} entity - The entity
325
+ * @param {string} primaryKeyName - The name of the primary key
326
+ * @param {Object | undefined} versionMetadata - Version field metadata
327
+ * @param {Record<string, AnyColumn>} columns - The table columns
328
+ * @param {T} schema - The table schema
329
+ * @returns {Promise<unknown>} The current version value
433
330
  */
434
- private optWhere<T>(
435
- updateModel: T,
436
- versionField: EntityProperty<T>,
437
- ): QBFilterQuery<unknown> {
438
- const currentVersionValue = transformValue(
439
- { value: updateModel[versionField.name], type: versionField.type },
440
- false,
331
+ private async getCurrentVersion<T extends AnyMySqlTable>(
332
+ entity: Partial<InferInsertModel<T>>,
333
+ primaryKeyName: string,
334
+ versionMetadata: { fieldName: string; type: string } | undefined,
335
+ columns: Record<string, AnyColumn>,
336
+ schema: T,
337
+ ): Promise<unknown> {
338
+ if (!versionMetadata || !columns) return undefined;
339
+
340
+ const versionField = columns[versionMetadata.fieldName];
341
+ if (!versionField) return undefined;
342
+
343
+ if (versionMetadata.fieldName in entity) {
344
+ return entity[versionMetadata.fieldName as keyof typeof entity];
345
+ }
346
+
347
+ const oldModel = await this.getOldModel(
348
+ { [primaryKeyName]: entity[primaryKeyName as keyof typeof entity] },
349
+ schema,
350
+ [versionMetadata.fieldName, versionField],
441
351
  );
442
- return { [versionField.name]: currentVersionValue };
352
+
353
+ return (oldModel as any)[versionMetadata.fieldName];
443
354
  }
444
355
 
445
356
  /**
446
- * Retrieves the current state of a record from the database.
447
- *
448
- * @param primaryKeys - The primary key properties.
449
- * @param entity - The entity with updated values.
450
- * @param schema - The entity schema.
451
- * @param versionField - The version field property.
452
- * @returns The existing record from the database.
453
- * @throws If the record does not exist or if multiple records are found.
357
+ * Prepares a model for insertion with version field.
358
+ * @template T - The type of the table schema
359
+ * @param {Partial<InferInsertModel<T>>} model - The model to prepare
360
+ * @param {Object | undefined} versionMetadata - Version field metadata
361
+ * @param {Record<string, AnyColumn>} columns - The table columns
362
+ * @returns {InferInsertModel<T>} The prepared model
454
363
  */
455
- private async getOldModel<T>(
456
- primaryKeys: EntityProperty<T, unknown>[],
457
- entity: T,
458
- schema: EntitySchema<T>,
459
- versionField: EntityProperty<T>,
460
- ): Promise<T> {
461
- const primaryFieldNames = primaryKeys.map((pk) => pk.name);
462
- const fieldsToSelect = primaryFieldNames.concat(versionField.name);
463
- const queryBuilder = this.forgeOperations
464
- .createQueryBuilder(schema as EntitySchema)
465
- .select(fieldsToSelect);
466
- this.addPrimaryWhere(queryBuilder, primaryKeys, entity);
467
- const formattedQuery = queryBuilder.getFormattedQuery();
468
- const models: T[] = await this.forgeOperations.fetch().executeSchemaSQL(formattedQuery, schema as EntitySchema);
469
-
470
- if (!models || models.length === 0) {
471
- throw new Error(`Cannot modify record because it does not exist in table ${schema.meta.tableName}`);
472
- }
473
- if (models.length > 1) {
474
- throw new Error(
475
- `Cannot modify record because multiple rows with the same ID were found in table ${schema.meta.tableName}. Please verify the table metadata.`,
476
- );
477
- }
478
- return models[0];
364
+ private prepareModelWithVersion<T extends AnyMySqlTable>(
365
+ model: Partial<InferInsertModel<T>>,
366
+ versionMetadata: { fieldName: string; type: string } | undefined,
367
+ columns: Record<string, AnyColumn>,
368
+ ): InferInsertModel<T> {
369
+ if (!versionMetadata || !columns) return model as InferInsertModel<T>;
370
+
371
+ const versionField = columns[versionMetadata.fieldName];
372
+ if (!versionField) return model as InferInsertModel<T>;
373
+
374
+ const modelWithVersion = { ...model };
375
+ const fieldType = versionField.getSQLType();
376
+ const versionValue = fieldType === "datetime" || fieldType === "timestamp" ? new Date() : 1;
377
+ modelWithVersion[versionMetadata.fieldName as keyof typeof modelWithVersion] =
378
+ versionValue as any;
379
+
380
+ return modelWithVersion as InferInsertModel<T>;
479
381
  }
480
382
 
481
383
  /**
482
- * Adds primary key conditions to the query builder.
483
- *
484
- * @param queryBuilder - The Knex query builder instance.
485
- * @param primaryKeys - The primary key properties.
486
- * @param entity - The entity containing primary key values.
487
- * @throws If any primary key value is missing.
384
+ * Prepares update data with version field.
385
+ * @template T - The type of the table schema
386
+ * @param {Partial<InferInsertModel<T>>} entity - The entity to update
387
+ * @param {Object | undefined} versionMetadata - Version field metadata
388
+ * @param {Record<string, AnyColumn>} columns - The table columns
389
+ * @param {unknown} currentVersion - The current version value
390
+ * @returns {Partial<InferInsertModel<T>>} The prepared update data
488
391
  */
489
- private addPrimaryWhere<T>(
490
- queryBuilder: Knex.QueryBuilder<any, any>,
491
- primaryKeys: EntityProperty<T, unknown>[],
492
- entity: T,
493
- ) {
494
- primaryKeys.forEach((pk) => {
495
- const fieldName = this.getRealFieldNameFromSchema(pk);
496
- const value = entity[fieldName];
497
- if (value === null || value === undefined) {
498
- throw new Error(`Primary key ${fieldName} must exist in the model`);
392
+ private prepareUpdateData<T extends AnyMySqlTable>(
393
+ entity: Partial<InferInsertModel<T>>,
394
+ versionMetadata: { fieldName: string; type: string } | undefined,
395
+ columns: Record<string, AnyColumn>,
396
+ currentVersion: unknown,
397
+ ): Partial<InferInsertModel<T>> {
398
+ const updateData = { ...entity };
399
+
400
+ if (versionMetadata && columns) {
401
+ const versionField = columns[versionMetadata.fieldName];
402
+ if (versionField) {
403
+ const fieldType = versionField.getSQLType();
404
+ updateData[versionMetadata.fieldName as keyof typeof updateData] =
405
+ fieldType === "datetime" || fieldType === "timestamp"
406
+ ? new Date()
407
+ : (((currentVersion as number) + 1) as any);
499
408
  }
500
- queryBuilder.andWhere({ [fieldName]: value });
501
- });
409
+ }
410
+
411
+ return updateData;
502
412
  }
503
413
 
504
414
  /**
505
- * Filters the entity to include only the specified fields.
506
- *
507
- * @param entity - The original entity.
508
- * @param fields - The list of fields to retain.
509
- * @returns A partial entity object containing only the specified fields.
415
+ * Retrieves an existing model by primary key.
416
+ * @template T - The type of the table schema
417
+ * @param {Record<string, unknown>} primaryKeyValues - The primary key values
418
+ * @param {T} schema - The table schema
419
+ * @param {[string, AnyColumn]} versionField - The version field name and column
420
+ * @returns {Promise<Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined>} The existing model
421
+ * @throws {Error} If the record is not found
510
422
  */
511
- filterEntityFields = <T extends object>(entity: T, fields: (keyof T)[]): Partial<T> =>
512
- fields.reduce((result, field) => {
513
- if (field in entity) {
514
- result[field] = entity[field];
515
- }
516
- return result;
517
- }, {} as Partial<T>);
423
+ private async getOldModel<T extends AnyMySqlTable>(
424
+ primaryKeyValues: Record<string, unknown>,
425
+ schema: T,
426
+ versionField: [string, AnyColumn],
427
+ ): Promise<
428
+ Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined
429
+ > {
430
+ const [versionFieldName, versionFieldColumn] = versionField;
431
+ const primaryKeys = this.getPrimaryKeys(schema);
432
+ const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
518
433
 
519
- /**
520
- * Transforms and modifies the updated entity model based on the schema.
521
- *
522
- * @param updatedEntity - The updated entity.
523
- * @param schema - The entity schema.
524
- * @returns The modified entity.
525
- */
526
- private modifyModel<T>(updatedEntity: T, schema: EntitySchema<T>): T {
527
- const modifiedModel: Record<string, any> = {};
528
- schema.meta.props
529
- .filter((p) => p.kind === "scalar")
530
- .forEach((p) => {
531
- const value = updatedEntity[p.name];
532
- if (value !== undefined && value !== null) {
533
- const fieldName = this.getRealFieldNameFromSchema(p);
534
- modifiedModel[fieldName] = transformValue({ value, type: p.type }, false);
535
- }
536
- });
537
- return modifiedModel as T;
538
- }
434
+ const resultQuery = this.forgeOperations
435
+ .getDrizzleQueryBuilder()
436
+ .select({
437
+ [primaryKeyName]: primaryKeyColumn as any,
438
+ [versionFieldName]: versionFieldColumn as any,
439
+ })
440
+ .from(schema)
441
+ .where(eq(primaryKeyColumn, primaryKeyValues[primaryKeyName]));
539
442
 
540
- /**
541
- * Returns the real field name from the entity property based on the schema.
542
- *
543
- * @param p - The entity property.
544
- * @returns The real field name.
545
- */
546
- private getRealFieldNameFromSchema<T>(p: EntityProperty<T>): EntityKey<T> {
547
- return p.fieldNames && p.fieldNames.length
548
- ? (p.fieldNames[0] as EntityKey<T>)
549
- : p.name;
550
- }
443
+ const model = await this.forgeOperations.fetch().executeQueryOnlyOne(resultQuery);
551
444
 
552
- /**
553
- * Validates the provided value.
554
- *
555
- * @param value - The value to validate.
556
- * @returns True if the value is valid, false otherwise.
557
- */
558
- isValid(value: any): boolean {
559
- if (value instanceof Date) {
560
- return !isNaN(value.getTime());
445
+ if (!model) {
446
+ throw new Error(`Record not found in table ${schema}`);
561
447
  }
562
- return true;
448
+
449
+ return model as Awaited<T> extends Array<any> ? Awaited<T>[number] : Awaited<T>;
563
450
  }
564
451
  }