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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-sql-orm",
3
- "version": "2.0.11",
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.4",
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;