forge-sql-orm 2.0.11 → 2.0.12
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/package.json +3 -3
- package/src/core/ForgeSQLCrudOperations.ts +425 -0
- package/src/core/ForgeSQLORM.ts +227 -0
- package/src/core/ForgeSQLQueryBuilder.ts +351 -0
- package/src/core/ForgeSQLSelectOperations.ts +93 -0
- package/src/core/SystemTables.ts +10 -0
- package/src/index.ts +11 -0
- package/src/lib/drizzle/extensions/selectAliased.ts +74 -0
- package/src/lib/drizzle/extensions/types.d.ts +14 -0
- package/src/utils/forgeDriver.ts +39 -0
- package/src/utils/sqlUtils.ts +382 -0
- package/src/webtriggers/applyMigrationsWebTrigger.ts +49 -0
- package/src/webtriggers/dropMigrationWebTrigger.ts +51 -0
- package/src/webtriggers/fetchSchemaWebTrigger.ts +96 -0
- package/src/webtriggers/index.ts +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-sql-orm",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.12",
|
|
4
4
|
"description": "Drizzle ORM integration for Forge-SQL in Atlassian Forge applications.",
|
|
5
5
|
"main": "dist/ForgeSQLORM.js",
|
|
6
6
|
"module": "dist/ForgeSQLORM.mjs",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"typescript": "^5.8.2",
|
|
58
58
|
"typescript-eslint": "^8.29.0",
|
|
59
59
|
"uuid": "^11.1.0",
|
|
60
|
-
"vite": "^6.2.
|
|
60
|
+
"vite": "^6.2.5",
|
|
61
61
|
"vite-plugin-static-copy": "^2.3.0",
|
|
62
62
|
"vitest": "^3.1.1"
|
|
63
63
|
},
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"files": [
|
|
82
82
|
"dist",
|
|
83
83
|
"dist-cli",
|
|
84
|
-
"node_modules",
|
|
85
84
|
"tsconfig.json",
|
|
85
|
+
"src",
|
|
86
86
|
"README.md"
|
|
87
87
|
],
|
|
88
88
|
"peerDependencies": {
|
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
import { ForgeSqlOrmOptions } from "..";
|
|
2
|
+
import { CRUDForgeSQL, ForgeSqlOperation } from "./ForgeSQLQueryBuilder";
|
|
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
|
+
*/
|
|
13
|
+
export class ForgeSQLCrudOperations implements CRUDForgeSQL {
|
|
14
|
+
private readonly forgeOperations: ForgeSqlOperation;
|
|
15
|
+
private readonly options: ForgeSqlOrmOptions;
|
|
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
|
+
*/
|
|
22
|
+
constructor(forgeSqlOperations: ForgeSqlOperation, options: ForgeSqlOrmOptions) {
|
|
23
|
+
this.forgeOperations = forgeSqlOperations;
|
|
24
|
+
this.options = options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Inserts records into the database with optional versioning support.
|
|
29
|
+
* If a version field exists in the schema, versioning is applied.
|
|
30
|
+
*
|
|
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
|
|
37
|
+
*/
|
|
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;
|
|
44
|
+
|
|
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),
|
|
51
|
+
);
|
|
52
|
+
|
|
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,
|
|
65
|
+
})
|
|
66
|
+
: queryBuilder;
|
|
67
|
+
|
|
68
|
+
// Execute query
|
|
69
|
+
const result = await finalQuery;
|
|
70
|
+
return result[0].insertId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Deletes a record by its primary key with optional version check.
|
|
75
|
+
* If versioning is enabled, ensures the record hasn't been modified since last read.
|
|
76
|
+
*
|
|
77
|
+
* @template T - The type of the table schema
|
|
78
|
+
* @param {unknown} id - The ID of the record to delete
|
|
79
|
+
* @param {T} schema - The entity schema
|
|
80
|
+
* @returns {Promise<number>} Number of affected rows
|
|
81
|
+
* @throws {Error} If the delete operation fails
|
|
82
|
+
* @throws {Error} If multiple primary keys are found
|
|
83
|
+
*/
|
|
84
|
+
async deleteById<T extends AnyMySqlTable>(id: unknown, schema: T): Promise<number> {
|
|
85
|
+
const { tableName, columns } = getTableMetadata(schema);
|
|
86
|
+
const primaryKeys = this.getPrimaryKeys(schema);
|
|
87
|
+
|
|
88
|
+
if (primaryKeys.length !== 1) {
|
|
89
|
+
throw new Error("Only single primary key is supported");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
|
|
93
|
+
const versionMetadata = this.validateVersionField(tableName, columns);
|
|
94
|
+
|
|
95
|
+
// Build delete conditions
|
|
96
|
+
const conditions: SQL<unknown>[] = [eq(primaryKeyColumn, id)];
|
|
97
|
+
|
|
98
|
+
// Add version check if needed
|
|
99
|
+
if (versionMetadata && columns) {
|
|
100
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
101
|
+
if (versionField) {
|
|
102
|
+
const oldModel = await this.getOldModel({ [primaryKeyName]: id }, schema, [
|
|
103
|
+
versionMetadata.fieldName,
|
|
104
|
+
versionField,
|
|
105
|
+
]);
|
|
106
|
+
conditions.push(eq(versionField, (oldModel as any)[versionMetadata.fieldName]));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Execute delete query
|
|
111
|
+
const queryBuilder = this.forgeOperations
|
|
112
|
+
.getDrizzleQueryBuilder()
|
|
113
|
+
.delete(schema)
|
|
114
|
+
.where(and(...conditions));
|
|
115
|
+
|
|
116
|
+
const result = await queryBuilder;
|
|
117
|
+
|
|
118
|
+
return result[0].affectedRows;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Updates a record by its primary key with optimistic locking support.
|
|
123
|
+
* If versioning is enabled:
|
|
124
|
+
* - Retrieves the current version
|
|
125
|
+
* - Checks for concurrent modifications
|
|
126
|
+
* - Increments the version on successful update
|
|
127
|
+
*
|
|
128
|
+
* @template T - The type of the table schema
|
|
129
|
+
* @param {Partial<InferInsertModel<T>>} entity - The entity with updated values
|
|
130
|
+
* @param {T} schema - The entity schema
|
|
131
|
+
* @returns {Promise<number>} Number of affected rows
|
|
132
|
+
* @throws {Error} If the primary key is not provided
|
|
133
|
+
* @throws {Error} If optimistic locking check fails
|
|
134
|
+
* @throws {Error} If multiple primary keys are found
|
|
135
|
+
*/
|
|
136
|
+
async updateById<T extends AnyMySqlTable>(
|
|
137
|
+
entity: Partial<InferInsertModel<T>>,
|
|
138
|
+
schema: T,
|
|
139
|
+
): Promise<number> {
|
|
140
|
+
const { tableName, columns } = getTableMetadata(schema);
|
|
141
|
+
const primaryKeys = this.getPrimaryKeys(schema);
|
|
142
|
+
|
|
143
|
+
if (primaryKeys.length !== 1) {
|
|
144
|
+
throw new Error("Only single primary key is supported");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
|
|
148
|
+
const versionMetadata = this.validateVersionField(tableName, columns);
|
|
149
|
+
|
|
150
|
+
// Validate primary key
|
|
151
|
+
if (!(primaryKeyName in entity)) {
|
|
152
|
+
throw new Error(`Primary key ${primaryKeyName} must be provided in the entity`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Get current version if needed
|
|
156
|
+
const currentVersion = await this.getCurrentVersion(
|
|
157
|
+
entity,
|
|
158
|
+
primaryKeyName,
|
|
159
|
+
versionMetadata,
|
|
160
|
+
columns,
|
|
161
|
+
schema,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Prepare update data with version
|
|
165
|
+
const updateData = this.prepareUpdateData(entity, versionMetadata, columns, currentVersion);
|
|
166
|
+
|
|
167
|
+
// Build update conditions
|
|
168
|
+
const conditions: SQL<unknown>[] = [
|
|
169
|
+
eq(primaryKeyColumn, entity[primaryKeyName as keyof typeof entity]),
|
|
170
|
+
];
|
|
171
|
+
if (versionMetadata && columns) {
|
|
172
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
173
|
+
if (versionField) {
|
|
174
|
+
conditions.push(eq(versionField, currentVersion));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Execute update query
|
|
179
|
+
const queryBuilder = this.forgeOperations
|
|
180
|
+
.getDrizzleQueryBuilder()
|
|
181
|
+
.update(schema)
|
|
182
|
+
.set(updateData)
|
|
183
|
+
.where(and(...conditions));
|
|
184
|
+
|
|
185
|
+
const result = await queryBuilder;
|
|
186
|
+
// Check optimistic locking
|
|
187
|
+
if (versionMetadata && result[0].affectedRows === 0) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Optimistic locking failed: record with primary key ${entity[primaryKeyName as keyof typeof entity]} has been modified`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return result[0].affectedRows;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Updates specified fields of records based on provided conditions.
|
|
198
|
+
* This method does not support versioning and should be used with caution.
|
|
199
|
+
*
|
|
200
|
+
* @template T - The type of the table schema
|
|
201
|
+
* @param {Partial<InferInsertModel<T>>} updateData - The data to update
|
|
202
|
+
* @param {T} schema - The entity schema
|
|
203
|
+
* @param {SQL<unknown>} where - The WHERE conditions
|
|
204
|
+
* @returns {Promise<number>} Number of affected rows
|
|
205
|
+
* @throws {Error} If WHERE conditions are not provided
|
|
206
|
+
* @throws {Error} If the update operation fails
|
|
207
|
+
*/
|
|
208
|
+
async updateFields<T extends AnyMySqlTable>(
|
|
209
|
+
updateData: Partial<InferInsertModel<T>>,
|
|
210
|
+
schema: T,
|
|
211
|
+
where?: SQL<unknown>,
|
|
212
|
+
): Promise<number> {
|
|
213
|
+
if (!where) {
|
|
214
|
+
throw new Error("WHERE conditions must be provided");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const queryBuilder = this.forgeOperations
|
|
218
|
+
.getDrizzleQueryBuilder()
|
|
219
|
+
.update(schema)
|
|
220
|
+
.set(updateData)
|
|
221
|
+
.where(where);
|
|
222
|
+
|
|
223
|
+
const result = await queryBuilder;
|
|
224
|
+
return result[0].affectedRows;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Helper methods
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Gets primary keys from the schema.
|
|
231
|
+
* @template T - The type of the table schema
|
|
232
|
+
* @param {T} schema - The table schema
|
|
233
|
+
* @returns {[string, AnyColumn][]} Array of primary key name and column pairs
|
|
234
|
+
* @throws {Error} If no primary keys are found
|
|
235
|
+
*/
|
|
236
|
+
private getPrimaryKeys<T extends AnyMySqlTable>(schema: T): [string, AnyColumn][] {
|
|
237
|
+
const primaryKeys = getPrimaryKeys(schema);
|
|
238
|
+
if (!primaryKeys) {
|
|
239
|
+
throw new Error(`No primary keys found for schema: ${schema}`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return primaryKeys;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Validates and retrieves version field metadata.
|
|
247
|
+
* @param {string} tableName - The name of the table
|
|
248
|
+
* @param {Record<string, AnyColumn>} columns - The table columns
|
|
249
|
+
* @returns {Object | undefined} Version field metadata if valid, undefined otherwise
|
|
250
|
+
*/
|
|
251
|
+
private validateVersionField(
|
|
252
|
+
tableName: string,
|
|
253
|
+
columns: Record<string, AnyColumn>,
|
|
254
|
+
): { fieldName: string; type: string } | undefined {
|
|
255
|
+
if (this.options.disableOptimisticLocking) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
const versionMetadata = this.options.additionalMetadata?.[tableName]?.versionField;
|
|
259
|
+
if (!versionMetadata) return undefined;
|
|
260
|
+
|
|
261
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
262
|
+
if (!versionField) {
|
|
263
|
+
console.warn(
|
|
264
|
+
`Version field "${versionMetadata.fieldName}" not found in table ${tableName}. Versioning will be skipped.`,
|
|
265
|
+
);
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!versionField.notNull) {
|
|
270
|
+
console.warn(
|
|
271
|
+
`Version field "${versionMetadata.fieldName}" in table ${tableName} is nullable. Versioning may not work correctly.`,
|
|
272
|
+
);
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const fieldType = versionField.getSQLType();
|
|
277
|
+
const isSupportedType =
|
|
278
|
+
fieldType === "datetime" ||
|
|
279
|
+
fieldType === "timestamp" ||
|
|
280
|
+
fieldType === "int" ||
|
|
281
|
+
fieldType === "number" ||
|
|
282
|
+
fieldType === "decimal";
|
|
283
|
+
|
|
284
|
+
if (!isSupportedType) {
|
|
285
|
+
console.warn(
|
|
286
|
+
`Version field "${versionMetadata.fieldName}" in table ${tableName} has unsupported type "${fieldType}". ` +
|
|
287
|
+
`Only datetime, timestamp, int, and decimal types are supported for versioning. Versioning will be skipped.`,
|
|
288
|
+
);
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return { fieldName: versionMetadata.fieldName, type: fieldType };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Gets the current version of an entity.
|
|
297
|
+
* @template T - The type of the table schema
|
|
298
|
+
* @param {Partial<InferInsertModel<T>>} entity - The entity
|
|
299
|
+
* @param {string} primaryKeyName - The name of the primary key
|
|
300
|
+
* @param {Object | undefined} versionMetadata - Version field metadata
|
|
301
|
+
* @param {Record<string, AnyColumn>} columns - The table columns
|
|
302
|
+
* @param {T} schema - The table schema
|
|
303
|
+
* @returns {Promise<unknown>} The current version value
|
|
304
|
+
*/
|
|
305
|
+
private async getCurrentVersion<T extends AnyMySqlTable>(
|
|
306
|
+
entity: Partial<InferInsertModel<T>>,
|
|
307
|
+
primaryKeyName: string,
|
|
308
|
+
versionMetadata: { fieldName: string; type: string } | undefined,
|
|
309
|
+
columns: Record<string, AnyColumn>,
|
|
310
|
+
schema: T,
|
|
311
|
+
): Promise<unknown> {
|
|
312
|
+
if (!versionMetadata || !columns) return undefined;
|
|
313
|
+
|
|
314
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
315
|
+
if (!versionField) return undefined;
|
|
316
|
+
|
|
317
|
+
if (versionMetadata.fieldName in entity) {
|
|
318
|
+
return entity[versionMetadata.fieldName as keyof typeof entity];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const oldModel = await this.getOldModel(
|
|
322
|
+
{ [primaryKeyName]: entity[primaryKeyName as keyof typeof entity] },
|
|
323
|
+
schema,
|
|
324
|
+
[versionMetadata.fieldName, versionField],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return (oldModel as any)[versionMetadata.fieldName];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Prepares a model for insertion with version field.
|
|
332
|
+
* @template T - The type of the table schema
|
|
333
|
+
* @param {Partial<InferInsertModel<T>>} model - The model to prepare
|
|
334
|
+
* @param {Object | undefined} versionMetadata - Version field metadata
|
|
335
|
+
* @param {Record<string, AnyColumn>} columns - The table columns
|
|
336
|
+
* @returns {InferInsertModel<T>} The prepared model
|
|
337
|
+
*/
|
|
338
|
+
private prepareModelWithVersion<T extends AnyMySqlTable>(
|
|
339
|
+
model: Partial<InferInsertModel<T>>,
|
|
340
|
+
versionMetadata: { fieldName: string; type: string } | undefined,
|
|
341
|
+
columns: Record<string, AnyColumn>,
|
|
342
|
+
): InferInsertModel<T> {
|
|
343
|
+
if (!versionMetadata || !columns) return model as InferInsertModel<T>;
|
|
344
|
+
|
|
345
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
346
|
+
if (!versionField) return model as InferInsertModel<T>;
|
|
347
|
+
|
|
348
|
+
const modelWithVersion = { ...model };
|
|
349
|
+
const fieldType = versionField.getSQLType();
|
|
350
|
+
const versionValue = fieldType === "datetime" || fieldType === "timestamp" ? new Date() : 1;
|
|
351
|
+
modelWithVersion[versionMetadata.fieldName as keyof typeof modelWithVersion] =
|
|
352
|
+
versionValue as any;
|
|
353
|
+
|
|
354
|
+
return modelWithVersion as InferInsertModel<T>;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Prepares update data with version field.
|
|
359
|
+
* @template T - The type of the table schema
|
|
360
|
+
* @param {Partial<InferInsertModel<T>>} entity - The entity to update
|
|
361
|
+
* @param {Object | undefined} versionMetadata - Version field metadata
|
|
362
|
+
* @param {Record<string, AnyColumn>} columns - The table columns
|
|
363
|
+
* @param {unknown} currentVersion - The current version value
|
|
364
|
+
* @returns {Partial<InferInsertModel<T>>} The prepared update data
|
|
365
|
+
*/
|
|
366
|
+
private prepareUpdateData<T extends AnyMySqlTable>(
|
|
367
|
+
entity: Partial<InferInsertModel<T>>,
|
|
368
|
+
versionMetadata: { fieldName: string; type: string } | undefined,
|
|
369
|
+
columns: Record<string, AnyColumn>,
|
|
370
|
+
currentVersion: unknown,
|
|
371
|
+
): Partial<InferInsertModel<T>> {
|
|
372
|
+
const updateData = { ...entity };
|
|
373
|
+
|
|
374
|
+
if (versionMetadata && columns) {
|
|
375
|
+
const versionField = columns[versionMetadata.fieldName];
|
|
376
|
+
if (versionField) {
|
|
377
|
+
const fieldType = versionField.getSQLType();
|
|
378
|
+
updateData[versionMetadata.fieldName as keyof typeof updateData] =
|
|
379
|
+
fieldType === "datetime" || fieldType === "timestamp"
|
|
380
|
+
? new Date()
|
|
381
|
+
: (((currentVersion as number) + 1) as any);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return updateData;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Retrieves an existing model by primary key.
|
|
390
|
+
* @template T - The type of the table schema
|
|
391
|
+
* @param {Record<string, unknown>} primaryKeyValues - The primary key values
|
|
392
|
+
* @param {T} schema - The table schema
|
|
393
|
+
* @param {[string, AnyColumn]} versionField - The version field name and column
|
|
394
|
+
* @returns {Promise<Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined>} The existing model
|
|
395
|
+
* @throws {Error} If the record is not found
|
|
396
|
+
*/
|
|
397
|
+
private async getOldModel<T extends AnyMySqlTable>(
|
|
398
|
+
primaryKeyValues: Record<string, unknown>,
|
|
399
|
+
schema: T,
|
|
400
|
+
versionField: [string, AnyColumn],
|
|
401
|
+
): Promise<
|
|
402
|
+
Awaited<T> extends Array<any> ? Awaited<T>[number] | undefined : Awaited<T> | undefined
|
|
403
|
+
> {
|
|
404
|
+
const [versionFieldName, versionFieldColumn] = versionField;
|
|
405
|
+
const primaryKeys = this.getPrimaryKeys(schema);
|
|
406
|
+
const [primaryKeyName, primaryKeyColumn] = primaryKeys[0];
|
|
407
|
+
|
|
408
|
+
const resultQuery = this.forgeOperations
|
|
409
|
+
.getDrizzleQueryBuilder()
|
|
410
|
+
.select({
|
|
411
|
+
[primaryKeyName]: primaryKeyColumn as any,
|
|
412
|
+
[versionFieldName]: versionFieldColumn as any,
|
|
413
|
+
})
|
|
414
|
+
.from(schema)
|
|
415
|
+
.where(eq(primaryKeyColumn, primaryKeyValues[primaryKeyName]));
|
|
416
|
+
|
|
417
|
+
const model = await this.forgeOperations.fetch().executeQueryOnlyOne(resultQuery);
|
|
418
|
+
|
|
419
|
+
if (!model) {
|
|
420
|
+
throw new Error(`Record not found in table ${schema}`);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return model as Awaited<T> extends Array<any> ? Awaited<T>[number] : Awaited<T>;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { ForgeSQLCrudOperations } from "./ForgeSQLCrudOperations";
|
|
2
|
+
import {
|
|
3
|
+
CRUDForgeSQL,
|
|
4
|
+
ForgeSqlOperation,
|
|
5
|
+
ForgeSqlOrmOptions,
|
|
6
|
+
SchemaSqlForgeSql,
|
|
7
|
+
} from "./ForgeSQLQueryBuilder";
|
|
8
|
+
import { ForgeSQLSelectOperations } from "./ForgeSQLSelectOperations";
|
|
9
|
+
import { drizzle, MySqlRemoteDatabase, MySqlRemotePreparedQueryHKT } from "drizzle-orm/mysql-proxy";
|
|
10
|
+
import { forgeDriver } from "../utils/forgeDriver";
|
|
11
|
+
import type { SelectedFields } from "drizzle-orm/mysql-core/query-builders/select.types";
|
|
12
|
+
import { MySqlSelectBuilder } from "drizzle-orm/mysql-core";
|
|
13
|
+
import {patchDbWithSelectAliased} from "../lib/drizzle/extensions/selectAliased";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Implementation of ForgeSQLORM that uses Drizzle ORM for query building.
|
|
17
|
+
* This class provides a bridge between Forge SQL and Drizzle ORM, allowing
|
|
18
|
+
* to use Drizzle's query builder while executing queries through Forge SQL.
|
|
19
|
+
*/
|
|
20
|
+
class ForgeSQLORMImpl implements ForgeSqlOperation {
|
|
21
|
+
private static instance: ForgeSQLORMImpl | null = null;
|
|
22
|
+
private readonly drizzle;
|
|
23
|
+
private readonly crudOperations: CRUDForgeSQL;
|
|
24
|
+
private readonly fetchOperations: SchemaSqlForgeSql;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Private constructor to enforce singleton behavior.
|
|
28
|
+
* @param options - Options for configuring ForgeSQL ORM behavior.
|
|
29
|
+
*/
|
|
30
|
+
private constructor(options?: ForgeSqlOrmOptions) {
|
|
31
|
+
try {
|
|
32
|
+
const newOptions: ForgeSqlOrmOptions = options ?? {
|
|
33
|
+
logRawSqlQuery: false,
|
|
34
|
+
disableOptimisticLocking: false,
|
|
35
|
+
};
|
|
36
|
+
if (newOptions.logRawSqlQuery) {
|
|
37
|
+
console.debug("Initializing ForgeSQLORM...");
|
|
38
|
+
}
|
|
39
|
+
// Initialize Drizzle instance with our custom driver
|
|
40
|
+
this.drizzle = patchDbWithSelectAliased(drizzle(forgeDriver, { logger: newOptions.logRawSqlQuery }));
|
|
41
|
+
this.crudOperations = new ForgeSQLCrudOperations(this, newOptions);
|
|
42
|
+
this.fetchOperations = new ForgeSQLSelectOperations(newOptions);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error("ForgeSQLORM initialization failed:", error);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Returns the singleton instance of ForgeSQLORMImpl.
|
|
51
|
+
* @param options - Options for configuring ForgeSQL ORM behavior.
|
|
52
|
+
* @returns The singleton instance of ForgeSQLORMImpl.
|
|
53
|
+
*/
|
|
54
|
+
static getInstance(options?: ForgeSqlOrmOptions): ForgeSqlOperation {
|
|
55
|
+
if (!ForgeSQLORMImpl.instance) {
|
|
56
|
+
ForgeSQLORMImpl.instance = new ForgeSQLORMImpl(options);
|
|
57
|
+
}
|
|
58
|
+
return ForgeSQLORMImpl.instance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Retrieves the CRUD operations instance.
|
|
63
|
+
* @returns CRUD operations.
|
|
64
|
+
*/
|
|
65
|
+
crud(): CRUDForgeSQL {
|
|
66
|
+
return this.crudOperations;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Retrieves the fetch operations instance.
|
|
71
|
+
* @returns Fetch operations.
|
|
72
|
+
*/
|
|
73
|
+
fetch(): SchemaSqlForgeSql {
|
|
74
|
+
return this.fetchOperations;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns a Drizzle query builder instance.
|
|
79
|
+
*
|
|
80
|
+
* ⚠️ IMPORTANT: This method should be used ONLY for query building purposes.
|
|
81
|
+
* The returned instance should NOT be used for direct database connections or query execution.
|
|
82
|
+
* All database operations should be performed through Forge SQL's executeRawSQL or executeRawUpdateSQL methods.
|
|
83
|
+
*
|
|
84
|
+
* @returns A Drizzle query builder instance for query construction only.
|
|
85
|
+
*/
|
|
86
|
+
getDrizzleQueryBuilder(): MySqlRemoteDatabase<Record<string, unknown>> {
|
|
87
|
+
return this.drizzle;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates a select query with unique field aliases to prevent field name collisions in joins.
|
|
92
|
+
* This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
|
|
93
|
+
*
|
|
94
|
+
* @template TSelection - The type of the selected fields
|
|
95
|
+
* @param {TSelection} fields - Object containing the fields to select, with table schemas as values
|
|
96
|
+
* @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A select query builder with unique field aliases
|
|
97
|
+
* @throws {Error} If fields parameter is empty
|
|
98
|
+
* @example
|
|
99
|
+
* ```typescript
|
|
100
|
+
* await forgeSQL
|
|
101
|
+
* .select({user: users, order: orders})
|
|
102
|
+
* .from(orders)
|
|
103
|
+
* .innerJoin(users, eq(orders.userId, users.id));
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
select<TSelection extends SelectedFields>(
|
|
107
|
+
fields: TSelection,
|
|
108
|
+
): MySqlSelectBuilder<TSelection, MySqlRemotePreparedQueryHKT> {
|
|
109
|
+
if (!fields) {
|
|
110
|
+
throw new Error("fields is empty");
|
|
111
|
+
}
|
|
112
|
+
return this.drizzle.selectAliased(fields);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Creates a distinct select query with unique field aliases to prevent field name collisions in joins.
|
|
117
|
+
* This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
|
|
118
|
+
*
|
|
119
|
+
* @template TSelection - The type of the selected fields
|
|
120
|
+
* @param {TSelection} fields - Object containing the fields to select, with table schemas as values
|
|
121
|
+
* @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A distinct select query builder with unique field aliases
|
|
122
|
+
* @throws {Error} If fields parameter is empty
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* await forgeSQL
|
|
126
|
+
* .selectDistinct({user: users, order: orders})
|
|
127
|
+
* .from(orders)
|
|
128
|
+
* .innerJoin(users, eq(orders.userId, users.id));
|
|
129
|
+
* ```
|
|
130
|
+
*/
|
|
131
|
+
selectDistinct<TSelection extends SelectedFields>(
|
|
132
|
+
fields: TSelection,
|
|
133
|
+
): MySqlSelectBuilder<TSelection, MySqlRemotePreparedQueryHKT> {
|
|
134
|
+
if (!fields) {
|
|
135
|
+
throw new Error("fields is empty");
|
|
136
|
+
}
|
|
137
|
+
return this.drizzle.selectAliasedDistinct(fields);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Public class that acts as a wrapper around the private ForgeSQLORMImpl.
|
|
143
|
+
* Provides a clean interface for working with Forge SQL and Drizzle ORM.
|
|
144
|
+
*/
|
|
145
|
+
class ForgeSQLORM implements ForgeSqlOperation {
|
|
146
|
+
private readonly ormInstance: ForgeSqlOperation;
|
|
147
|
+
|
|
148
|
+
constructor(options?: ForgeSqlOrmOptions) {
|
|
149
|
+
this.ormInstance = ForgeSQLORMImpl.getInstance(options);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates a select query with unique field aliases to prevent field name collisions in joins.
|
|
154
|
+
* This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
|
|
155
|
+
*
|
|
156
|
+
* @template TSelection - The type of the selected fields
|
|
157
|
+
* @param {TSelection} fields - Object containing the fields to select, with table schemas as values
|
|
158
|
+
* @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A select query builder with unique field aliases
|
|
159
|
+
* @throws {Error} If fields parameter is empty
|
|
160
|
+
* @example
|
|
161
|
+
* ```typescript
|
|
162
|
+
* await forgeSQL
|
|
163
|
+
* .select({user: users, order: orders})
|
|
164
|
+
* .from(orders)
|
|
165
|
+
* .innerJoin(users, eq(orders.userId, users.id));
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
select<TSelection extends SelectedFields>(
|
|
169
|
+
fields: TSelection,
|
|
170
|
+
): MySqlSelectBuilder<TSelection, MySqlRemotePreparedQueryHKT> {
|
|
171
|
+
return this.ormInstance.select(fields);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Creates a distinct select query with unique field aliases to prevent field name collisions in joins.
|
|
176
|
+
* This is particularly useful when working with Atlassian Forge SQL, which collapses fields with the same name in joined tables.
|
|
177
|
+
*
|
|
178
|
+
* @template TSelection - The type of the selected fields
|
|
179
|
+
* @param {TSelection} fields - Object containing the fields to select, with table schemas as values
|
|
180
|
+
* @returns {MySqlSelectBuilder<TSelection, MySql2PreparedQueryHKT>} A distinct select query builder with unique field aliases
|
|
181
|
+
* @throws {Error} If fields parameter is empty
|
|
182
|
+
* @example
|
|
183
|
+
* ```typescript
|
|
184
|
+
* await forgeSQL
|
|
185
|
+
* .selectDistinct({user: users, order: orders})
|
|
186
|
+
* .from(orders)
|
|
187
|
+
* .innerJoin(users, eq(orders.userId, users.id));
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
selectDistinct<TSelection extends SelectedFields>(
|
|
191
|
+
fields: TSelection,
|
|
192
|
+
): MySqlSelectBuilder<TSelection, MySqlRemotePreparedQueryHKT> {
|
|
193
|
+
return this.ormInstance
|
|
194
|
+
.selectDistinct(fields);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Proxies the `crud` method from `ForgeSQLORMImpl`.
|
|
199
|
+
* @returns CRUD operations.
|
|
200
|
+
*/
|
|
201
|
+
crud(): CRUDForgeSQL {
|
|
202
|
+
return this.ormInstance.crud();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Proxies the `fetch` method from `ForgeSQLORMImpl`.
|
|
207
|
+
* @returns Fetch operations.
|
|
208
|
+
*/
|
|
209
|
+
fetch(): SchemaSqlForgeSql {
|
|
210
|
+
return this.ormInstance.fetch();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Returns a Drizzle query builder instance.
|
|
215
|
+
*
|
|
216
|
+
* ⚠️ IMPORTANT: This method should be used ONLY for query building purposes.
|
|
217
|
+
* The returned instance should NOT be used for direct database connections or query execution.
|
|
218
|
+
* All database operations should be performed through Forge SQL's executeRawSQL or executeRawUpdateSQL methods.
|
|
219
|
+
*
|
|
220
|
+
* @returns A Drizzle query builder instance for query construction only.
|
|
221
|
+
*/
|
|
222
|
+
getDrizzleQueryBuilder() {
|
|
223
|
+
return this.ormInstance.getDrizzleQueryBuilder();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default ForgeSQLORM;
|