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

vzakharchenko / forge-sql-orm / 20001078276

07 Dec 2025 07:46AM UTC coverage: 82.985% (-1.8%) from 84.777%
20001078276

Pull #1174

github

web-flow
Merge e44bad915 into a1384f2f2
Pull Request #1174: improved cache sql parsing

812 of 1054 branches covered (77.04%)

Branch coverage included in aggregate %.

134 of 178 new or added lines in 1 file covered. (75.28%)

1373 of 1579 relevant lines covered (86.95%)

79.08 hits per line

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

73.38
/src/utils/cacheTableUtils.ts
1
import { Parser } from "node-sql-parser";
2
import { ForgeSqlOrmOptions } from "../core/ForgeSQLQueryBuilder";
3

4
/**
5
 * Extracts table name from object value.
6
 */
7
function extractTableNameFromObject(value: any, context?: string): string | null {
8
  // If it's an array, skip it (not a table name)
9
  if (Array.isArray(value)) {
14✔
10
    return null;
4✔
11
  }
12
  // Handle backticks_quote_string type only for node.table context
13
  if (
10!
14
    context?.includes("node.table") &&
30✔
15
    value.type === "backticks_quote_string" &&
16
    typeof value.value === "string"
17
  ) {
18
    return value.value === "dual" ? null : value.value.toLowerCase();
10!
19
  }
20
  // Try value.name first (most common)
NEW
21
  if (typeof value.name === "string") {
×
NEW
22
    return value.name === "dual" ? null : value.name.toLowerCase();
×
23
  }
24
  // Try value.table if it's a nested structure
NEW
25
  if (value.table) {
×
NEW
26
    return normalizeTableName(value.table, context);
×
27
  }
28
  // Log when we encounter an object that we can't extract table name from
29
  // eslint-disable-next-line no-console
NEW
30
  console.warn(
×
31
    `[cacheTableUtils] Unable to extract table name from object:`,
32
    JSON.stringify(value, null, 2),
33
    context ? `(context: ${context})` : "",
×
34
  );
35
  return null;
14✔
36
}
37

38
/**
39
 * Helper function to safely convert to string and lowercase.
40
 */
41
function normalizeTableName(value: any, context?: string): string | null {
42
  if (!value) {
334!
NEW
43
    return null;
×
44
  }
45
  // If it's already a string, use it
46
  if (typeof value === "string") {
334✔
47
    return value === "dual" ? null : value.toLowerCase();
320!
48
  }
49
  // If it's an object, try to extract name from various properties
50
  if (typeof value === "object") {
14!
51
    return extractTableNameFromObject(value, context);
14✔
52
  }
53
  // For other types (number, boolean, etc.), log and don't treat as table name
54
  // eslint-disable-next-line no-console
NEW
55
  console.warn(
×
56
    `[cacheTableUtils] Unexpected table name type:`,
57
    typeof value,
58
    value,
59
    context ? `(context: ${context})` : "",
×
60
  );
61
  return null;
334✔
62
}
63

64
/**
65
 * Checks if a node is a column reference alias.
66
 */
67
function isColumnRefAlias(node: any): boolean {
68
  return node.type === "column_ref" && !node.table;
1,611✔
69
}
70

71
/**
72
 * Checks if a node is an explicit alias (has 'as' property but is not a table node).
73
 */
74
function isExplicitAlias(node: any): boolean {
75
  return Boolean(node.as && node.type !== "table" && node.type !== "dual" && !node.table);
1,270✔
76
}
77

78
/**
79
 * Checks if a node has a short name that is likely an alias.
80
 */
81
function isShortNameAlias(node: any): boolean {
82
  if (!node.name || node.table || node.type === "table" || node.type === "dual") {
1,237✔
83
    return false;
1,186✔
84
  }
85
  const nameStr = typeof node.name === "string" ? node.name : node.name?.name || node.name?.value;
51✔
86
  return typeof nameStr === "string" && nameStr.length <= 2;
1,237✔
87
}
88

89
/**
90
 * Checks if a node is likely an alias (not a real table).
91
 */
92
function isLikelyAlias(node: any): boolean {
93
  return isColumnRefAlias(node) || isExplicitAlias(node) || isShortNameAlias(node);
1,611✔
94
}
95

96
/**
97
 * Extracts table name from table node.
98
 *
99
 * @param node - AST node with table information
100
 * @returns Table name in lowercase or null if not applicable
101
 */
102
function extractTableName(node: any): string | null {
103
  if (!node) {
1,611!
NEW
104
    return null;
×
105
  }
106

107
  // Early return for likely aliases
108
  if (isLikelyAlias(node)) {
1,611✔
109
    return null;
374✔
110
  }
111

112
  // Handle table node directly
113
  if (node.type === "table" || node.type === "dual") {
1,237!
NEW
114
    const fromTable = node.table
×
115
      ? normalizeTableName(node.table, `node.type=${node.type}, node.table`)
116
      : null;
NEW
117
    if (fromTable) {
×
NEW
118
      return fromTable;
×
119
    }
NEW
120
    const fromName = node.name
×
121
      ? normalizeTableName(node.name, `node.type=${node.type}, node.name`)
122
      : null;
NEW
123
    if (fromName) {
×
NEW
124
      return fromName;
×
125
    }
NEW
126
    return null;
×
127
  }
128

129
  // Handle table reference in different formats
130
  if (node.table) {
1,237✔
131
    const tableName = normalizeTableName(node.table, `node.table (type: ${node.type})`);
334✔
132
    if (tableName) {
334✔
133
      return tableName;
330✔
134
    }
135
  }
136

137
  return null;
907✔
138
}
139

140
/**
141
 * Processes and adds table name to the set if valid.
142
 */
143
function processTableName(node: any, tableName: string, tables: Set<string>): void {
144
  // Filter out a_ prefixed names (field aliases)
145
  if (tableName.startsWith("a_")) {
330!
NEW
146
    return;
×
147
  }
148

149
  // Filter out short names that are likely table aliases (u, us, o, oi, etc.)
150
  // Only filter if it's not a real table node (type === "table" or "dual")
151
  const isRealTableNode = node.type === "table" || node.type === "dual";
330✔
152
  if (!isRealTableNode && tableName.length <= 2) {
330✔
153
    return;
35✔
154
  }
155

156
  tables.add(tableName);
295✔
157
}
158

159
/**
160
 * Extracts table name from node and adds to set if valid.
161
 */
162
function extractAndAddTableName(node: any, tables: Set<string>): void {
163
  const tableName = extractTableName(node);
1,611✔
164
  if (tableName && tableName.length > 0) {
1,611✔
165
    processTableName(node, tableName, tables);
330✔
166
  }
167
}
168

169
/**
170
 * Processes CTE (Common Table Expressions) - WITH clause.
171
 */
172
function processCTE(node: any, tables: Set<string>): void {
173
  if (!node.with && !node.with_list) {
1,611✔
174
    return;
1,489✔
175
  }
176
  const withClauses = node.with_list || (Array.isArray(node.with) ? node.with : [node.with]);
122✔
177
  withClauses.forEach((cte: any) => {
1,611✔
178
    if (cte?.stmt) {
124✔
179
      extractTablesFromNode(cte.stmt, tables);
7✔
180
    }
181
    if (cte?.as?.stmt) {
124!
NEW
182
      extractTablesFromNode(cte.as.stmt, tables);
×
183
    }
184
  });
185
}
186

187
/**
188
 * Processes FROM and JOIN clauses.
189
 */
190
function processFromAndJoin(node: any, tables: Set<string>): void {
191
  if (node.from) {
1,611✔
192
    if (Array.isArray(node.from)) {
114!
193
      node.from.forEach((item: any) => extractTablesFromNode(item, tables));
127✔
194
    } else {
NEW
195
      extractTablesFromNode(node.from, tables);
×
196
    }
197
  }
198

199
  if (node.join) {
1,611✔
200
    if (Array.isArray(node.join)) {
138!
NEW
201
      node.join.forEach((item: any) => extractTablesFromNode(item, tables));
×
202
    } else {
203
      extractTablesFromNode(node.join, tables);
138✔
204
    }
205
  }
206
}
207

208
/**
209
 * Processes SELECT columns that may contain subqueries.
210
 */
211
function processSelectColumns(node: any, tables: Set<string>): void {
212
  const columns = node.columns || node.select;
1,611✔
213
  if (!columns) {
1,611✔
214
    return;
1,467✔
215
  }
216

217
  if (Array.isArray(columns)) {
144!
218
    columns.forEach((col: any) => {
144✔
219
      if (!col) return;
173!
220

221
      // If the column itself is a subquery
222
      if (col.type === "subquery" || col.type === "select") {
173!
NEW
223
        extractTablesFromNode(col, tables);
×
224
      }
225

226
      // Process expression (may contain subqueries)
227
      if (col.expr) {
173✔
228
        extractTablesFromNode(col.expr, tables);
143✔
229
      }
230

231
      // Process AST (alternative structure for subqueries)
232
      if (col.ast) {
173!
NEW
233
        extractTablesFromNode(col.ast, tables);
×
234
      }
235
    });
NEW
236
  } else if (typeof columns === "object") {
×
NEW
237
    extractTablesFromNode(columns, tables);
×
238
  }
239
}
240

241
/**
242
 * Processes ORDER BY or GROUP BY clause.
243
 */
244
function processOrderByOrGroupBy(clause: any, tables: Set<string>): void {
245
  if (!clause) {
3,222✔
246
    return;
3,207✔
247
  }
248
  if (Array.isArray(clause)) {
15✔
249
    clause.forEach((item: any) => {
1✔
250
      if (item?.expr) {
1!
251
        extractTablesFromNode(item.expr, tables);
1✔
252
      }
253
      extractTablesFromNode(item, tables);
1✔
254
    });
255
  } else {
256
    extractTablesFromNode(clause, tables);
14✔
257
  }
258
}
259

260
/**
261
 * Processes UNION operations.
262
 */
263
function processUnionNode(unionNode: any, tables: Set<string>): void {
NEW
264
  if (!unionNode) {
×
NEW
265
    return;
×
266
  }
267

268
  const isUnionType =
NEW
269
    unionNode.type === "select" ||
×
270
    unionNode.type === "union" ||
271
    unionNode.type === "union_all" ||
272
    unionNode.type === "union_distinct" ||
273
    unionNode.type === "intersect" ||
274
    unionNode.type === "except" ||
275
    unionNode.type === "minus";
276

NEW
277
  if (isUnionType) {
×
NEW
278
    extractTablesFromNode(unionNode, tables);
×
NEW
279
  } else if (unionNode.select) {
×
NEW
280
    extractTablesFromNode(unionNode.select, tables);
×
NEW
281
  } else if (unionNode.ast) {
×
NEW
282
    extractTablesFromNode(unionNode.ast, tables);
×
283
  } else {
NEW
284
    extractTablesFromNode(unionNode, tables);
×
285
  }
286
}
287

288
/**
289
 * Processes UNION/UNION ALL/UNION DISTINCT/INTERSECT/EXCEPT/MINUS clauses.
290
 */
291
function processUnion(node: any, tables: Set<string>): void {
292
  if (!node.union) {
1,611!
293
    return;
1,611✔
294
  }
295

NEW
296
  if (Array.isArray(node.union)) {
×
NEW
297
    node.union.forEach((unionNode: any) => processUnionNode(unionNode, tables));
×
NEW
298
  } else if (typeof node.union === "object") {
×
NEW
299
    processUnionNode(node.union, tables);
×
300
  }
301
}
302

303
/**
304
 * Processes UNION/INTERSECT/EXCEPT operation nodes.
305
 */
306
function processUnionOperation(node: any, tables: Set<string>): void {
307
  const isUnionOperation =
308
    node.type === "union" ||
1,611✔
309
    node.type === "union_all" ||
310
    node.type === "union_distinct" ||
311
    node.type === "intersect" ||
312
    node.type === "except" ||
313
    node.type === "minus";
314

315
  if (!isUnionOperation) {
1,611!
316
    return;
1,611✔
317
  }
318

NEW
319
  if (node.left) {
×
NEW
320
    extractTablesFromNode(node.left, tables);
×
321
  }
NEW
322
  if (node.right) {
×
NEW
323
    extractTablesFromNode(node.right, tables);
×
324
  }
NEW
325
  extractTablesFromNode(node, tables);
×
326
}
327

328
/**
329
 * Processes _next property (alternative UNION structure).
330
 */
331
function processNext(node: any, tables: Set<string>): void {
332
  if (!node._next) {
1,611✔
333
    return;
1,604✔
334
  }
335
  if (Array.isArray(node._next)) {
7!
NEW
336
    node._next.forEach((nextNode: any) => extractTablesFromNode(nextNode, tables));
×
337
  } else {
338
    extractTablesFromNode(node._next, tables);
7✔
339
  }
340
}
341

342
/**
343
 * Recursively processes all object properties for any remaining nested structures.
344
 */
345
function processRecursively(node: any, tables: Set<string>): void {
346
  const isLikelyAlias =
347
    (node.type === "column_ref" && !node.table) ||
1,611✔
348
    (node.name &&
349
      !node.table &&
350
      node.type !== "table" &&
351
      node.type !== "dual" &&
352
      node.name.length <= 2);
353

354
  if (isLikelyAlias || Array.isArray(node)) {
1,611✔
355
    return;
458✔
356
  }
357

358
  Object.values(node).forEach((value) => {
1,153✔
359
    if (value && typeof value === "object") {
4,514✔
360
      if (Array.isArray(value)) {
1,167✔
361
        value.forEach((item: any) => {
416✔
362
          if (item && typeof item === "object") {
566✔
363
            extractTablesFromNode(item, tables);
336✔
364
          }
365
        });
366
      } else {
367
        extractTablesFromNode(value, tables);
751✔
368
      }
369
    }
370
  });
371
}
372

373
/**
374
 * Recursively extracts table names from SQL AST node.
375
 * Handles regular tables, CTEs, subqueries, and complex query structures.
376
 *
377
 * @param node - AST node to extract tables from
378
 * @param tables - Accumulator set for table names
379
 */
380
function extractTablesFromNode(node: any, tables: Set<string>): void {
381
  if (!node || typeof node !== "object") {
1,749✔
382
    return;
138✔
383
  }
384

385
  // Extract table name if node is a table type
386
  extractAndAddTableName(node, tables);
1,611✔
387

388
  // Handle CTE (Common Table Expressions) - WITH clause
389
  processCTE(node, tables);
1,611✔
390

391
  // Extract tables from FROM and JOIN clauses
392
  processFromAndJoin(node, tables);
1,611✔
393

394
  // Handle subqueries explicitly
395
  if (node.type === "subquery" || node.type === "select") {
1,611✔
396
    if (node.ast) {
115!
NEW
397
      extractTablesFromNode(node.ast, tables);
×
398
    }
399
    if (node.from) {
115✔
400
      extractTablesFromNode(node.from, tables);
113✔
401
    }
402
  }
403

404
  // Extract tables from WHERE clause (may contain subqueries)
405
  if (node.where) {
1,611✔
406
    extractTablesFromNode(node.where, tables);
48✔
407
  }
408

409
  // Extract tables from SELECT columns (may contain subqueries)
410
  processSelectColumns(node, tables);
1,611✔
411

412
  // Extract tables from HAVING clause (may contain subqueries)
413
  if (node.having) {
1,611✔
414
    extractTablesFromNode(node.having, tables);
2✔
415
  }
416

417
  // Extract tables from ORDER BY clause (may contain subqueries)
418
  processOrderByOrGroupBy(node.orderby || node.order_by, tables);
1,611✔
419

420
  // Extract tables from GROUP BY clause (may contain subqueries)
421
  processOrderByOrGroupBy(node.groupby || node.group_by, tables);
1,749✔
422

423
  // Extract tables from UPDATE statement
424
  if (node.type === "update" && node.table) {
1,749✔
425
    extractTablesFromNode(node.table, tables);
2✔
426
  }
427

428
  // Extract tables from INSERT statement
429
  if (node.type === "insert" && node.table) {
1,611✔
430
    extractTablesFromNode(node.table, tables);
1✔
431
  }
432

433
  // Extract tables from DELETE statement
434
  if (node.type === "delete" && node.from) {
1,611✔
435
    extractTablesFromNode(node.from, tables);
1✔
436
  }
437

438
  // Extract tables from UNION operations
439
  processUnion(node, tables);
1,611✔
440

441
  // Handle node types for UNION/INTERSECT/EXCEPT operations
442
  processUnionOperation(node, tables);
1,611✔
443

444
  // Handle _next property (alternative UNION structure)
445
  processNext(node, tables);
1,611✔
446

447
  // Recursively process all object properties
448
  processRecursively(node, tables);
1,611✔
449
}
450

451
/**
452
 * Extracts all table names from SQL query using node-sql-parser, with regex fallback.
453
 * Returns them as comma-separated string in format `table1`,`table2`.
454
 *
455
 * @param sql - SQL query string
456
 * @param options - ForgeSQL ORM options for logging
457
 * @returns Comma-separated string of unique table names in backticks
458
 */
459
export function extractBacktickedValues(sql: string, options: ForgeSqlOrmOptions): string {
460
  // Try to use node-sql-parser first
461
  try {
63✔
462
    const parser = new Parser();
63✔
463
    const ast = parser.astify(sql.trim());
63✔
464

465
    const tables = new Set<string>();
63✔
466

467
    // Handle both single statement and multiple statements
468
    const statements = Array.isArray(ast) ? ast : [ast];
63✔
469
    statements.forEach((statement) => {
63✔
470
      extractTablesFromNode(statement, tables);
57✔
471
    });
472

473
    if (tables.size > 0) {
63✔
474
      // Sort to ensure consistent order for the same input
475
      const backtickedValues = Array.from(tables)
55✔
476
        .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
49✔
477
        .map((table) => `\`${table}\``)
88✔
478
        .join(",");
479
      if (options.logCache) {
55✔
480
        // eslint-disable-next-line no-console
481
        console.warn(`Extracted backticked values: ${backtickedValues}`);
12✔
482
      }
483
      return backtickedValues;
55✔
484
    }
485
  } catch (error) {
486
    if (options.logCache) {
5✔
487
      // eslint-disable-next-line no-console
488
      console.error(
3✔
489
        `Error extracting backticked values: ${error}. Using regex-based extraction instead.`,
490
      );
491
    }
492
    // If parsing fails, fall back to regex-based extraction
493
    // This handles cases where node-sql-parser doesn't support the SQL syntax
494
  }
495

496
  // Fallback to regex-based extraction (original logic)
497
  const regex = /`([^`]+)`/g;
8✔
498
  const matches = new Set<string>();
8✔
499
  let match;
500

501
  while ((match = regex.exec(sql.toLowerCase())) !== null) {
8✔
502
    if (!match[1].startsWith("a_")) {
11✔
503
      matches.add(`\`${match[1]}\``);
7✔
504
    }
505
  }
506

507
  // Sort to ensure consistent order for the same input
508
  return Array.from(matches)
8✔
509
    .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }))
3✔
510
    .join(",");
511
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc