• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

vzakharchenko / forge-sql-orm / 19835826516

01 Dec 2025 08:03PM UTC coverage: 84.96% (-0.1%) from 85.065%
19835826516

Pull #1164

github

web-flow
Merge ef85cab3e into 01872bea1
Pull Request #1164: fix issues

665 of 845 branches covered (78.7%)

Branch coverage included in aggregate %.

27 of 32 new or added lines in 1 file covered. (84.38%)

23 existing lines in 1 file now uncovered.

1250 of 1409 relevant lines covered (88.72%)

20.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

81.12
/src/utils/sqlUtils.ts
1
import {
2
  and,
3
  AnyColumn,
4
  Column,
5
  gte,
6
  ilike,
7
  isNotNull,
8
  isTable,
9
  ne,
10
  not,
11
  notInArray,
12
  SQL,
13
  sql,
14
  StringChunk,
15
} from "drizzle-orm";
16
import { AnyMySqlTable, MySqlCustomColumn } from "drizzle-orm/mysql-core/index";
17
import { DateTime } from "luxon";
18
import { PrimaryKeyBuilder } from "drizzle-orm/mysql-core/primary-keys";
19
import { AnyIndexBuilder } from "drizzle-orm/mysql-core/indexes";
20
import { CheckBuilder } from "drizzle-orm/mysql-core/checks";
21
import { ForeignKeyBuilder } from "drizzle-orm/mysql-core/foreign-keys";
22
import { UniqueConstraintBuilder } from "drizzle-orm/mysql-core/unique-constraint";
23
import { SelectedFields } from "drizzle-orm/mysql-core/query-builders/select.types";
24
import { MySqlTable } from "drizzle-orm/mysql-core";
25
import { isSQLWrapper } from "drizzle-orm/sql/sql";
26
import { clusterStatementsSummary, slowQuery } from "../core/SystemTables";
27
import { ForgeSqlOperation } from "../core/ForgeSQLQueryBuilder";
28
import { ColumnDataType } from "drizzle-orm/column-builder";
29
import { AnyMySqlColumn } from "drizzle-orm/mysql-core/columns/common";
30
import type { ColumnBaseConfig } from "drizzle-orm/column";
31

32
/**
33
 * Interface representing table metadata information
34
 */
35
export interface MetadataInfo {
36
  /** The name of the table */
37
  tableName: string;
38
  /** Record of column names and their corresponding column definitions */
39
  columns: Record<string, AnyColumn>;
40
  /** Array of index builders */
41
  indexes: AnyIndexBuilder[];
42
  /** Array of check constraint builders */
43
  checks: CheckBuilder[];
44
  /** Array of foreign key builders */
45
  foreignKeys: ForeignKeyBuilder[];
46
  /** Array of primary key builders */
47
  primaryKeys: PrimaryKeyBuilder[];
48
  /** Array of unique constraint builders */
49
  uniqueConstraints: UniqueConstraintBuilder[];
50
  /** Array of all extra builders */
51
  extras: any[];
52
}
53

54
/**
55
 * Interface for config builder data
56
 */
57
interface ConfigBuilderData {
58
  value?: any;
59
  [key: string]: any;
60
}
61

62
/**
63
 * Parses a date string into a Date object using the specified format
64
 * @param value - The date string to parse or Date
65
 * @param format - The format to use for parsing
66
 * @returns Date object
67
 */
68

69
export const parseDateTime = (value: string | Date, format: string): Date => {
7✔
70
  let result: Date;
71
  if (value instanceof Date) {
23✔
72
    result = value;
2✔
73
  } else {
74
    // 1. Try to parse using the provided format (strict mode)
75
    const dt = DateTime.fromFormat(value, format);
21✔
76
    if (dt.isValid) {
21✔
77
      result = dt.toJSDate();
7✔
78
    } else {
79
      // 2. Try to parse as SQL string
80
      const sqlDt = DateTime.fromSQL(value);
14✔
81
      if (sqlDt.isValid) {
14✔
82
        result = sqlDt.toJSDate();
4✔
83
      } else {
84
        // 3. Try to parse as RFC2822 string
85
        const isoDt = DateTime.fromRFC2822(value);
10✔
86
        if (isoDt.isValid) {
10!
87
          result = isoDt.toJSDate();
×
88
        } else {
89
          // 4. Fallback: use native Date constructor
90
          result = new Date(value);
10✔
91
        }
92
      }
93
    }
94
  }
95
  // 4. Ensure the result is a valid Date object
96
  if (Number.isNaN(result.getTime())) {
23✔
97
    result = new Date(value);
7✔
98
  }
99
  return result;
23✔
100
};
101

102
/**
103
 * Parses a string value into DateTime using multiple format parsers
104
 */
105
function parseStringToDateTime(value: string): DateTime | null {
106
  const parsers = [DateTime.fromISO, DateTime.fromRFC2822, DateTime.fromSQL, DateTime.fromHTTP];
3✔
107

108
  for (const parser of parsers) {
3✔
109
    const dt = parser(value);
8✔
110
    if (dt.isValid) {
8✔
111
      return dt;
2✔
112
    }
113
  }
114

115
  // Try parsing as number string
116
  const parsed = Number(value);
1✔
117
  if (!Number.isNaN(parsed)) {
1!
118
    return DateTime.fromMillis(parsed);
×
119
  }
120

121
  return null;
1✔
122
}
123

124
/**
125
 * Converts a value to DateTime
126
 */
127
function valueToDateTime(value: Date | string | number): DateTime {
128
  let dt: DateTime | null = null;
35✔
129

130
  if (value instanceof Date) {
35✔
131
    dt = DateTime.fromJSDate(value);
29✔
132
  } else if (typeof value === "string") {
6✔
133
    dt = parseStringToDateTime(value);
3✔
134
  } else if (typeof value === "number") {
3✔
135
    dt = DateTime.fromMillis(value);
1✔
136
  } else {
137
    throw new Error("Unsupported type");
2✔
138
  }
139

140
  if (!dt?.isValid) {
33✔
141
    throw new Error("Invalid Date");
1✔
142
  }
143

144
  return dt;
32✔
145
}
146

147
/**
148
 * Validates timestamp range for Atlassian Forge compatibility
149
 */
150
function validateTimestampRange(dt: DateTime): void {
151
  const minDate = DateTime.fromSeconds(1);
7✔
152
  const maxDate = DateTime.fromMillis(2147483647 * 1000); // 2038-01-19 03:14:07.999 UTC
7✔
153

154
  if (dt < minDate) {
7✔
155
    throw new Error(
1✔
156
      "Atlassian Forge does not support zero or negative timestamps. Allowed range: from '1970-01-01 00:00:01.000000' to '2038-01-19 03:14:07.999999'.",
157
    );
158
  }
159

160
  if (dt > maxDate) {
6✔
161
    throw new Error(
1✔
162
      "Atlassian Forge does not support timestamps beyond 2038-01-19 03:14:07.999999. Please use a smaller date within the supported range.",
163
    );
164
  }
165
}
166

167
/**
168
 * Helper function to validate and format a date-like value using Luxon DateTime.
169
 * @param value - Date object, ISO/RFC2822/SQL/HTTP string, or timestamp (number|string).
170
 * @param format - DateTime format string (Luxon format tokens).
171
 * @param isTimeStamp - Whether to validate timestamp range
172
 * @returns Formatted date string.
173
 * @throws Error if value cannot be parsed as a valid date.
174
 */
175
export function formatDateTime(
176
  value: Date | string | number,
177
  format: string,
178
  isTimeStamp: boolean,
179
): string {
180
  const dt = valueToDateTime(value);
35✔
181

182
  if (isTimeStamp) {
35✔
183
    validateTimestampRange(dt);
7✔
184
  }
185

186
  return dt.toFormat(format);
30✔
187
}
188

189
/**
190
 * Gets primary keys from the schema.
191
 * @template T - The type of the table schema
192
 * @param {T} table - The table schema
193
 * @returns {[string, AnyColumn][]} Array of primary key name and column pairs
194
 */
195
export function getPrimaryKeys<T extends AnyMySqlTable>(table: T): [string, AnyColumn][] {
196
  const { columns, primaryKeys } = getTableMetadata(table);
14✔
197

198
  // First try to find primary keys in columns
199
  const columnPrimaryKeys = Object.entries(columns).filter(([, column]) => column.primary) as [
38✔
200
    string,
201
    AnyColumn,
202
  ][];
203

204
  if (columnPrimaryKeys.length > 0) {
14✔
205
    return columnPrimaryKeys;
10✔
206
  }
207

208
  // If no primary keys found in columns, check primary key builders
209
  if (Array.isArray(primaryKeys) && primaryKeys.length > 0) {
4!
210
    // Collect all primary key columns from all primary key builders
211
    const primaryKeyColumns = new Set<[string, AnyColumn]>();
4✔
212

213
    for (const primaryKeyBuilder of primaryKeys) {
4✔
214
      // Get primary key columns from each builder
215
      for (const [name, column1] of Object.entries(columns).filter(([, column]) => {
4✔
216
        // @ts-ignore - PrimaryKeyBuilder has internal columns property
217
        return primaryKeyBuilder.columns.includes(column);
8✔
218
      })) {
219
        primaryKeyColumns.add([name, column1]);
4✔
220
      }
221
    }
222

223
    return Array.from(primaryKeyColumns);
4✔
224
  }
225

226
  return [];
×
227
}
228

229
/**
230
 * Processes foreign keys from foreignKeysSymbol
231
 */
232
function processForeignKeysFromSymbol(
233
  table: AnyMySqlTable,
234
  foreignKeysSymbol: symbol,
235
): ForeignKeyBuilder[] {
236
  const foreignKeys: ForeignKeyBuilder[] = [];
46✔
237
  // @ts-ignore
238
  const fkArray: any[] = table[foreignKeysSymbol];
46✔
239

240
  if (!fkArray) {
46!
241
    return foreignKeys;
×
242
  }
243

244
  for (const fk of fkArray) {
46✔
245
    if (fk.reference) {
×
246
      const item = fk.reference(fk);
×
247
      foreignKeys.push(item);
×
248
    }
249
  }
250

251
  return foreignKeys;
46✔
252
}
253

254
/**
255
 * Extracts config builders from config builder data
256
 */
257
function extractConfigBuilders(configBuilderData: any): any[] {
258
  if (Array.isArray(configBuilderData)) {
21!
259
    return configBuilderData;
21✔
260
  }
261

262
  return Object.values(configBuilderData).map((item) => (item as ConfigBuilderData).value ?? item);
×
263
}
264

265
/**
266
 * Checks if a builder is a ForeignKeyBuilder
267
 */
268
function isForeignKeyBuilder(builder: any): boolean {
269
  if (!builder?.constructor) {
21!
270
    return false;
×
271
  }
272

273
  const builderName = builder.constructor.name.toLowerCase();
21✔
274
  return builderName.includes("foreignkeybuilder");
21✔
275
}
276

277
/**
278
 * Processes foreign keys from extraSymbol
279
 */
280
function processForeignKeysFromExtra(
281
  table: AnyMySqlTable,
282
  extraSymbol: symbol,
283
): ForeignKeyBuilder[] {
284
  const foreignKeys: ForeignKeyBuilder[] = [];
46✔
285
  // @ts-ignore
286
  const extraConfigBuilder = table[extraSymbol];
46✔
287

288
  if (!extraConfigBuilder || typeof extraConfigBuilder !== "function") {
46✔
289
    return foreignKeys;
25✔
290
  }
291

292
  const configBuilderData = extraConfigBuilder(table);
21✔
293
  if (!configBuilderData) {
21!
294
    return foreignKeys;
×
295
  }
296

297
  const configBuilders = extractConfigBuilders(configBuilderData);
21✔
298

299
  for (const builder of configBuilders) {
21✔
300
    if (isForeignKeyBuilder(builder)) {
21!
301
      foreignKeys.push(builder);
×
302
    }
303
  }
304

305
  return foreignKeys;
21✔
306
}
307

308
/**
309
 * Processes foreign keys from both foreignKeysSymbol and extraSymbol
310
 * @param table - The table schema
311
 * @param foreignKeysSymbol - Symbol for foreign keys
312
 * @param extraSymbol - Symbol for extra configuration
313
 * @returns Array of foreign key builders
314
 */
315
function processForeignKeys(
316
  table: AnyMySqlTable,
317
  foreignKeysSymbol: symbol | undefined,
318
  extraSymbol: symbol | undefined,
319
): ForeignKeyBuilder[] {
320
  const foreignKeys: ForeignKeyBuilder[] = [];
46✔
321

322
  // Process foreign keys from foreignKeysSymbol
323
  if (foreignKeysSymbol) {
46!
324
    foreignKeys.push(...processForeignKeysFromSymbol(table, foreignKeysSymbol));
46✔
325
  }
326

327
  // Process foreign keys from extraSymbol
328
  if (extraSymbol) {
46!
329
    foreignKeys.push(...processForeignKeysFromExtra(table, extraSymbol));
46✔
330
  }
331

332
  return foreignKeys;
46✔
333
}
334

335
/**
336
 * Extracts symbols from table schema.
337
 * @param table - The table schema
338
 * @returns Object containing relevant symbols
339
 */
340
function extractTableSymbols(table: AnyMySqlTable) {
341
  const symbols = Object.getOwnPropertySymbols(table);
46✔
342
  return {
46✔
343
    nameSymbol: symbols.find((s) => s.toString().includes("Name")),
46✔
344
    columnsSymbol: symbols.find((s) => s.toString().includes("Columns")),
184✔
345
    foreignKeysSymbol: symbols.find((s) => s.toString().includes("ForeignKeys)")),
460✔
346
    extraSymbol: symbols.find((s) => s.toString().includes("ExtraConfigBuilder")),
414✔
347
  };
348
}
349

350
/**
351
 * Converts config builder data to an array of builders.
352
 * @param configBuilderData - The config builder data (array or object)
353
 * @returns Array of builder objects
354
 */
355
function convertConfigBuildersToArray(configBuilderData: any): any[] {
356
  if (Array.isArray(configBuilderData)) {
21!
357
    return configBuilderData;
21✔
358
  }
NEW
359
  return Object.values(configBuilderData).map((item) => (item as ConfigBuilderData).value ?? item);
×
360
}
361

362
/**
363
 * Maps builder to appropriate array based on its type.
364
 * @param builder - The builder object
365
 * @param builders - The builders object containing all arrays
366
 * @returns True if builder was added to a specific array, false otherwise
367
 */
368
function addBuilderToTypedArray(
369
  builder: any,
370
  builders: {
371
    indexes: AnyIndexBuilder[];
372
    checks: CheckBuilder[];
373
    primaryKeys: PrimaryKeyBuilder[];
374
    uniqueConstraints: UniqueConstraintBuilder[];
375
  },
376
): boolean {
377
  if (!builder?.constructor) {
21!
NEW
378
    return false;
×
379
  }
380

381
  const builderName = builder.constructor.name.toLowerCase();
21✔
382
  const builderMap = {
21✔
383
    indexbuilder: builders.indexes,
384
    checkbuilder: builders.checks,
385
    primarykeybuilder: builders.primaryKeys,
386
    uniqueconstraintbuilder: builders.uniqueConstraints,
387
  };
388

389
  for (const [type, array] of Object.entries(builderMap)) {
21✔
390
    if (builderName.includes(type)) {
63✔
391
      array.push(builder);
21✔
392
      return true;
21✔
393
    }
394
  }
395

NEW
396
  return false;
×
397
}
398

399
/**
400
 * Processes extra configuration builders and adds them to the builders object.
401
 * @param table - The table schema
402
 * @param extraSymbol - The extra symbol from table
403
 * @param builders - The builders object to populate
404
 */
405
function processExtraConfigBuilders(
406
  table: AnyMySqlTable,
407
  extraSymbol: symbol | undefined,
408
  builders: {
409
    indexes: AnyIndexBuilder[];
410
    checks: CheckBuilder[];
411
    foreignKeys: ForeignKeyBuilder[];
412
    primaryKeys: PrimaryKeyBuilder[];
413
    uniqueConstraints: UniqueConstraintBuilder[];
414
    extras: any[];
415
  },
416
): void {
417
  if (!extraSymbol) {
46!
NEW
418
    return;
×
419
  }
420

421
  // @ts-ignore
422
  const extraConfigBuilder = table[extraSymbol];
46✔
423
  if (!extraConfigBuilder || typeof extraConfigBuilder !== "function") {
46✔
424
    return;
25✔
425
  }
426

427
  const configBuilderData = extraConfigBuilder(table);
21✔
428
  if (!configBuilderData) {
21!
NEW
UNCOV
429
    return;
×
430
  }
431

432
  const configBuilders = convertConfigBuildersToArray(configBuilderData);
21✔
433

434
  for (const builder of configBuilders) {
21✔
435
    addBuilderToTypedArray(builder, builders);
21✔
436
    builders.extras.push(builder);
21✔
437
  }
438
}
439

440
/**
441
 * Extracts table metadata from the schema.
442
 * @param {AnyMySqlTable} table - The table schema
443
 * @returns {MetadataInfo} Object containing table metadata
444
 */
445
export function getTableMetadata(table: AnyMySqlTable): MetadataInfo {
446
  const { nameSymbol, columnsSymbol, foreignKeysSymbol, extraSymbol } = extractTableSymbols(table);
46✔
447

448
  // Initialize builders arrays
449
  const builders = {
46✔
450
    indexes: [] as AnyIndexBuilder[],
451
    checks: [] as CheckBuilder[],
452
    foreignKeys: [] as ForeignKeyBuilder[],
453
    primaryKeys: [] as PrimaryKeyBuilder[],
454
    uniqueConstraints: [] as UniqueConstraintBuilder[],
455
    extras: [] as any[],
456
  };
457

458
  // Process foreign keys
459
  builders.foreignKeys = processForeignKeys(table, foreignKeysSymbol, extraSymbol);
46✔
460

461
  // Process extra configuration if available
462
  processExtraConfigBuilders(table, extraSymbol, builders);
46✔
463

464
  return {
46✔
465
    tableName: nameSymbol ? (table as any)[nameSymbol] : "",
46!
466
    columns: columnsSymbol ? ((table as any)[columnsSymbol] as Record<string, AnyColumn>) : {},
46!
467
    ...builders,
468
  };
469
}
470

471
/**
472
 * Generates SQL statements for dropping tables and/or their sequences.
473
 *
474
 * @param tables - List of table names to generate DROP statements for.
475
 * @param options - Configuration object:
476
 *   - sequence: whether to drop associated sequences (default: true)
477
 *   - table: whether to drop tables themselves (default: true)
478
 * @returns Array of SQL statements for dropping the specified objects
479
 */
480
export function generateDropTableStatements(
481
  tables: string[],
482
  options?: { sequence: boolean; table: boolean },
483
): string[] {
484
  const dropStatements: string[] = [];
7✔
485
  const validOptions = options ?? { sequence: true, table: true };
7✔
486
  if (!validOptions.sequence && !validOptions.table) {
7✔
487
    // eslint-disable-next-line no-console
488
    console.warn('No drop operations requested: both "table" and "sequence" options are false');
1✔
489
    return [];
1✔
490
  }
491
  for (const tableName of tables) {
6✔
492
    if (validOptions.table) {
8✔
493
      dropStatements.push(`DROP TABLE IF EXISTS \`${tableName}\`;`);
7✔
494
    }
495
    if (validOptions.sequence) {
8✔
496
      dropStatements.push(`DROP SEQUENCE IF EXISTS \`${tableName}\`;`);
7✔
497
    }
498
  }
499

500
  return dropStatements;
6✔
501
}
502

503
type AliasColumnMap = Record<string, AnyColumn>;
504

505
function mapSelectTableToAlias(
506
  table: MySqlTable,
507
  uniqPrefix: string,
508
  aliasMap: AliasColumnMap,
509
): any {
510
  const { columns, tableName } = getTableMetadata(table);
10✔
511
  const selectionsTableFields: Record<string, unknown> = {};
10✔
512
  for (const name of Object.keys(columns)) {
10✔
513
    const column = columns[name] as AnyColumn;
39✔
514
    const uniqName = `a_${uniqPrefix}_${tableName}_${column.name}`.toLowerCase();
39✔
515
    const fieldAlias = sql.raw(uniqName);
39✔
516
    selectionsTableFields[name] = sql`${column} as \`${fieldAlias}\``;
39✔
517
    aliasMap[uniqName] = column;
39✔
518
  }
519
  return selectionsTableFields;
10✔
520
}
521

522
function isDrizzleColumn(column: any): boolean {
523
  return column && typeof column === "object" && "table" in column;
144✔
524
}
525

526
export function mapSelectAllFieldsToAlias(
527
  selections: any,
528
  name: string,
529
  uniqName: string,
530
  fields: any,
531
  aliasMap: AliasColumnMap,
532
): any {
533
  if (isTable(fields)) {
154✔
534
    selections[name] = mapSelectTableToAlias(fields as MySqlTable, uniqName, aliasMap);
10✔
535
  } else if (isDrizzleColumn(fields)) {
144✔
536
    const column = fields as Column;
122✔
537
    const uniqAliasName = `a_${uniqName}_${column.name}`.toLowerCase();
122✔
538
    let aliasName = sql.raw(uniqAliasName);
122✔
539
    selections[name] = sql`${column} as \`${aliasName}\``;
122✔
540
    aliasMap[uniqAliasName] = column;
122✔
541
  } else if (isSQLWrapper(fields)) {
22✔
542
    selections[name] = fields;
10✔
543
  } else {
544
    const innerSelections: any = {};
12✔
545
    for (const [iname, ifields] of Object.entries(fields)) {
12✔
546
      mapSelectAllFieldsToAlias(innerSelections, iname, `${uniqName}_${iname}`, ifields, aliasMap);
24✔
547
    }
548
    selections[name] = innerSelections;
12✔
549
  }
550
  return selections;
154✔
551
}
552
export function mapSelectFieldsWithAlias<TSelection extends SelectedFields>(
553
  fields: TSelection,
554
): { selections: TSelection; aliasMap: AliasColumnMap } {
555
  if (!fields) {
53!
UNCOV
556
    throw new Error("fields is empty");
×
557
  }
558
  const aliasMap: AliasColumnMap = {};
53✔
559
  const selections: any = {};
53✔
560
  for (let i = 0; i < Object.entries(fields).length; i++) {
53✔
561
    const [name, fields1] = Object.entries(fields)[i];
130✔
562
    mapSelectAllFieldsToAlias(selections, name, name, fields1, aliasMap);
130✔
563
  }
564
  return { selections, aliasMap };
53✔
565
}
566

567
function getAliasFromDrizzleAlias(value: unknown): string | undefined {
568
  const isSQL =
569
    value !== null && typeof value === "object" && isSQLWrapper(value) && "queryChunks" in value;
230✔
570
  if (isSQL) {
230✔
571
    const sql = value as SQL;
187✔
572
    const queryChunks = sql.queryChunks;
187✔
573
    if (queryChunks.length > 3) {
187✔
574
      const aliasNameChunk = queryChunks[queryChunks.length - 2];
169✔
575
      if (isSQLWrapper(aliasNameChunk) && "queryChunks" in aliasNameChunk) {
169!
576
        const aliasNameChunkSql = aliasNameChunk as SQL;
169✔
577
        if (aliasNameChunkSql.queryChunks?.length === 1 && aliasNameChunkSql.queryChunks[0]) {
169!
578
          const queryChunksStringChunc = aliasNameChunkSql.queryChunks[0];
169✔
579
          if ("value" in queryChunksStringChunc) {
169!
580
            const values = (queryChunksStringChunc as StringChunk).value;
169✔
581
            if (values && values.length === 1) {
169!
582
              return values[0];
169✔
583
            }
584
          }
585
        }
586
      }
587
    }
588
  }
589
  return undefined;
61✔
590
}
591

592
function transformValue(
593
  value: unknown,
594
  alias: string,
595
  aliasMap: Record<string, AnyColumn>,
596
): unknown {
597
  const column = aliasMap[alias];
169✔
598
  if (!column) return value;
169!
599

600
  let customColumn = column as MySqlCustomColumn<any>;
169✔
601
  // @ts-ignore
602
  const fromDriver = customColumn?.mapFrom;
169✔
603
  if (fromDriver && value !== null && value !== undefined) {
169✔
604
    return fromDriver(value);
26✔
605
  }
606
  return value;
143✔
607
}
608

609
function transformObject(
610
  obj: Record<string, unknown>,
611
  selections: Record<string, unknown>,
612
  aliasMap: Record<string, AnyColumn>,
613
): Record<string, unknown> {
614
  const result: Record<string, unknown> = {};
84✔
615

616
  for (const [key, value] of Object.entries(obj)) {
84✔
617
    const selection = selections[key];
230✔
618
    const alias = getAliasFromDrizzleAlias(selection);
230✔
619
    if (alias && aliasMap[alias]) {
230✔
620
      result[key] = transformValue(value, alias, aliasMap);
169✔
621
    } else if (selection && typeof selection === "object" && !isSQLWrapper(selection)) {
61✔
622
      result[key] = transformObject(
43✔
623
        value as Record<string, unknown>,
624
        selection as Record<string, unknown>,
625
        aliasMap,
626
      );
627
    } else {
628
      result[key] = value;
18✔
629
    }
630
  }
631

632
  return result;
84✔
633
}
634

635
export function applyFromDriverTransform<T, TSelection>(
636
  rows: T[],
637
  selections: TSelection,
638
  aliasMap: Record<string, AnyColumn>,
639
): T[] {
640
  return rows.map((row) => {
33✔
641
    const transformed = transformObject(
41✔
642
      row as Record<string, unknown>,
643
      selections as Record<string, unknown>,
644
      aliasMap,
645
    ) as Record<string, unknown>;
646

647
    return processNullBranches(transformed) as unknown as T;
41✔
648
  });
649
}
650

651
function processNullBranches(obj: Record<string, unknown>): Record<string, unknown> | null {
652
  if (obj === null || typeof obj !== "object") {
92!
UNCOV
653
    return obj;
×
654
  }
655

656
  // Skip built-in objects like Date, Array, etc.
657
  if (obj.constructor && obj.constructor.name !== "Object") {
92✔
658
    return obj;
8✔
659
  }
660

661
  const result: Record<string, unknown> = {};
84✔
662
  let allNull = true;
84✔
663

664
  for (const [key, value] of Object.entries(obj)) {
84✔
665
    if (value === null || value === undefined) {
230✔
666
      result[key] = null;
5✔
667
      continue;
5✔
668
    }
669

670
    if (typeof value === "object") {
225✔
671
      const processed = processNullBranches(value as Record<string, unknown>);
51✔
672
      result[key] = processed;
51✔
673
      if (processed !== null) {
51✔
674
        allNull = false;
50✔
675
      }
676
    } else {
677
      result[key] = value;
174✔
678
      allNull = false;
174✔
679
    }
680
  }
681

682
  return allNull ? null : result;
84✔
683
}
684

685
export function formatLimitOffset(limitOrOffset: number): number {
686
  if (typeof limitOrOffset !== "number" || Number.isNaN(limitOrOffset)) {
5✔
687
    throw new Error("limitOrOffset must be a valid number");
2✔
688
  }
689
  return sql.raw(`${limitOrOffset}`) as unknown as number;
3✔
690
}
691

692
export function nextVal(sequenceName: string): number {
693
  return sql.raw(`NEXTVAL(${sequenceName})`) as unknown as number;
3✔
694
}
695

696
/**
697
 * Analyzes and prints query performance data from CLUSTER_STATEMENTS_SUMMARY table.
698
 *
699
 * This function queries the CLUSTER_STATEMENTS_SUMMARY table to find queries that were executed
700
 * within the specified time window and prints detailed performance information including:
701
 * - SQL query text
702
 * - Memory usage (average and max in MB)
703
 * - Execution time (average in ms)
704
 * - Number of executions
705
 * - Execution plan
706
 *
707
 * @param forgeSQLORM - The ForgeSQL operation instance for database access
708
 * @param timeDiffMs - Time window in milliseconds to look back for queries (e.g., 1500 for last 1.5 seconds)
709
 * @param timeout - Optional timeout in milliseconds for the query execution (defaults to 1500ms)
710
 *
711
 * @example
712
 * ```typescript
713
 * // Analyze queries from the last 2 seconds
714
 * await printQueriesWithPlan(forgeSQLORM, 2000);
715
 *
716
 * // Analyze queries with custom timeout
717
 * await printQueriesWithPlan(forgeSQLORM, 1000, 3000);
718
 * ```
719
 *
720
 * @throws Does not throw - errors are logged to console.debug instead
721
 */
722
export async function printQueriesWithPlan(
723
  forgeSQLORM: ForgeSqlOperation,
724
  timeDiffMs: number,
725
  timeout?: number,
726
) {
UNCOV
727
  try {
×
UNCOV
728
    const statementsTable = clusterStatementsSummary;
×
UNCOV
729
    const timeoutMs = timeout ?? 3000;
×
UNCOV
730
    const results = await withTimeout(
×
731
      forgeSQLORM
732
        .getDrizzleQueryBuilder()
733
        .select({
734
          digestText: withTidbHint(statementsTable.digestText),
735
          avgLatency: statementsTable.avgLatency,
736
          avgMem: statementsTable.avgMem,
737
          execCount: statementsTable.execCount,
738
          plan: statementsTable.plan,
739
          stmtType: statementsTable.stmtType,
740
        })
741
        .from(statementsTable)
742
        .where(
743
          and(
744
            isNotNull(statementsTable.digest),
745
            not(ilike(statementsTable.digestText, "%information_schema%")),
746
            notInArray(statementsTable.stmtType, [
747
              "Use",
748
              "Set",
749
              "Show",
750
              "Commit",
751
              "Rollback",
752
              "Begin",
753
            ]),
754
            gte(
755
              statementsTable.lastSeen,
756
              sql`DATE_SUB
757
                            (NOW(), INTERVAL
758
                            ${timeDiffMs * 1000}
759
                            MICROSECOND
760
                            )`,
761
            ),
762
          ),
763
        ),
764
      `Timeout ${timeoutMs}ms in printQueriesWithPlan - transient timeouts are usually fine; repeated timeouts mean this diagnostic query is consistently slow and should be investigated`,
765
      timeoutMs + 200,
766
    );
767

UNCOV
768
    for (const result of results) {
×
769
      // Average execution time (convert from nanoseconds to milliseconds)
UNCOV
770
      const avgTimeMs = Number(result.avgLatency) / 1_000_000;
×
UNCOV
771
      const avgMemMB = Number(result.avgMem) / 1_000_000;
×
772

773
      // 1. Query info: SQL, memory, time, executions
774
      // eslint-disable-next-line no-console
UNCOV
775
      console.warn(
×
776
        `SQL: ${result.digestText} | Memory: ${avgMemMB.toFixed(2)} MB | Time: ${avgTimeMs.toFixed(2)} ms | stmtType: ${result.stmtType} | Executions: ${result.execCount}\n Plan:${result.plan}`,
777
      );
778
    }
779
  } catch (error) {
780
    // eslint-disable-next-line no-console
UNCOV
781
    console.debug(
×
782
      `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}. Try again after some time`,
×
783
      error,
784
    );
785
  }
786
}
787

788
const SESSION_ALIAS_NAME_ORM = "orm";
7✔
789

790
/**
791
 * Analyzes and logs slow queries from the last specified number of hours.
792
 *
793
 * This function queries the slow query system table to find queries that were executed
794
 * within the specified time window and logs detailed performance information including:
795
 * - SQL query text
796
 * - Maximum memory usage (in MB)
797
 * - Query execution time (in ms)
798
 * - Execution count
799
 * - Execution plan
800
 *
801
 * @param forgeSQLORM - The ForgeSQL operation instance for database access
802
 * @param hours - Number of hours to look back for slow queries (e.g., 1 for last hour, 24 for last day)
803
 * @param timeout - Optional timeout in milliseconds for the query execution (defaults to 1500ms)
804
 *
805
 * @example
806
 * ```typescript
807
 * // Analyze slow queries from the last hour
808
 * await slowQueryPerHours(forgeSQLORM, 1);
809
 *
810
 * // Analyze slow queries from the last 24 hours with custom timeout
811
 * await slowQueryPerHours(forgeSQLORM, 24, 3000);
812
 *
813
 * // Analyze slow queries from the last 6 hours
814
 * await slowQueryPerHours(forgeSQLORM, 6);
815
 * ```
816
 *
817
 * @throws Does not throw - errors are logged to console.debug instead
818
 */
819
export async function slowQueryPerHours(
820
  forgeSQLORM: ForgeSqlOperation,
821
  hours: number,
822
  timeout?: number,
823
) {
UNCOV
824
  try {
×
UNCOV
825
    const timeoutMs = timeout ?? 1500;
×
UNCOV
826
    const results = await withTimeout(
×
827
      forgeSQLORM
828
        .getDrizzleQueryBuilder()
829
        .select({
830
          query: withTidbHint(slowQuery.query),
831
          queryTime: slowQuery.queryTime,
832
          memMax: slowQuery.memMax,
833
          plan: slowQuery.plan,
834
        })
835
        .from(slowQuery)
836
        .where(
837
          and(
838
            isNotNull(slowQuery.digest),
839
            ne(slowQuery.sessionAlias, SESSION_ALIAS_NAME_ORM),
840
            gte(
841
              slowQuery.time,
842
              sql`DATE_SUB
843
                            (NOW(), INTERVAL
844
                            ${hours}
845
                            HOUR
846
                            )`,
847
            ),
848
          ),
849
        ),
850
      `Timeout ${timeoutMs}ms in slowQueryPerHours - transient timeouts are usually fine; repeated timeouts mean this diagnostic query is consistently slow and should be investigated`,
851
      timeoutMs,
852
    );
UNCOV
853
    const response: string[] = [];
×
UNCOV
854
    for (const result of results) {
×
855
      // Convert memory from bytes to MB and handle null values
UNCOV
856
      const memMaxMB = result.memMax ? Number(result.memMax) / 1_000_000 : 0;
×
857

UNCOV
858
      const message = `Found SlowQuery SQL: ${result.query} | Memory: ${memMaxMB.toFixed(2)} MB | Time: ${result.queryTime} ms\n Plan:${result.plan}`;
×
859
      response.push(message);
×
860
      // 1. Query info: SQL, memory, time, executions
861
      // eslint-disable-next-line no-console
UNCOV
862
      console.warn(message);
×
863
    }
UNCOV
864
    return response;
×
865
  } catch (error) {
866
    // eslint-disable-next-line no-console
UNCOV
867
    console.debug(
×
868
      `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}. Try again after some time`,
×
869
      error,
870
    );
UNCOV
871
    return [
×
872
      `Error occurred while retrieving query execution plan: ${error instanceof Error ? error.message : "Unknown error"}`,
×
873
    ];
874
  }
875
}
876

877
/**
878
 * Executes a promise with a timeout.
879
 *
880
 * @param promise - The promise to execute
881
 * @param timeoutMs - Timeout in milliseconds
882
 * @returns Promise that resolves with the result or rejects on timeout
883
 * @throws {Error} When the operation times out
884
 */
885
export async function withTimeout<T>(
886
  promise: Promise<T>,
887
  message: string,
888
  timeoutMs: number,
889
): Promise<T> {
890
  let timeoutId: ReturnType<typeof setTimeout> | undefined;
891

892
  const timeoutPromise = new Promise<never>((_, reject) => {
100✔
893
    timeoutId = setTimeout(() => {
100✔
894
      reject(new Error(message));
1✔
895
    }, timeoutMs);
896
  });
897

898
  try {
100✔
899
    return await Promise.race([promise, timeoutPromise]);
100✔
900
  } finally {
901
    if (timeoutId) {
100!
902
      clearTimeout(timeoutId);
100✔
903
    }
904
  }
905
}
906

907
export function withTidbHint<
908
  TDataType extends ColumnDataType,
909
  TPartial extends Partial<ColumnBaseConfig<TDataType, string>>,
910
>(column: AnyMySqlColumn<TPartial>): AnyMySqlColumn<TPartial> {
911
  // We lie a bit to TypeScript here: at runtime this is a new SQL fragment,
912
  // but returning TExpr keeps the column type info in downstream inference.
913
  return sql`/*+ SET_VAR(tidb_session_alias=${sql.raw(`${SESSION_ALIAS_NAME_ORM}`)}) */ ${column}` as unknown as AnyMySqlColumn<TPartial>;
2✔
914
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc