@query-doctor/core 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/explain/rewriter.d.ts +4 -0
  2. package/dist/explain/rewriter.d.ts.map +1 -0
  3. package/dist/explain/traverse.d.ts +3 -0
  4. package/dist/explain/traverse.d.ts.map +1 -0
  5. package/dist/explain/tree.d.ts +73 -0
  6. package/dist/explain/tree.d.ts.map +1 -0
  7. package/dist/index.cjs +202 -36
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.js +202 -36
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +25333 -0
  12. package/dist/index.mjs.map +1 -0
  13. package/dist/optimizer/genalgo.d.ts +7 -0
  14. package/dist/optimizer/genalgo.d.ts.map +1 -1
  15. package/dist/optimizer/genalgo.test.d.ts +2 -0
  16. package/dist/optimizer/genalgo.test.d.ts.map +1 -0
  17. package/dist/optimizer/index-candidate.d.ts +23 -0
  18. package/dist/optimizer/index-candidate.d.ts.map +1 -0
  19. package/dist/optimizer/index-shrinker.d.ts +10 -0
  20. package/dist/optimizer/index-shrinker.d.ts.map +1 -0
  21. package/dist/optimizer/index-shrinker.test.d.ts +2 -0
  22. package/dist/optimizer/index-shrinker.test.d.ts.map +1 -0
  23. package/dist/optimizer/index-tester.d.ts +2 -0
  24. package/dist/optimizer/index-tester.d.ts.map +1 -0
  25. package/dist/optimizer/pss-rewriter.test.d.ts +2 -0
  26. package/dist/optimizer/pss-rewriter.test.d.ts.map +1 -0
  27. package/dist/optimizer/statistics.d.ts +9 -0
  28. package/dist/optimizer/statistics.d.ts.map +1 -1
  29. package/dist/sql/analyzer.d.ts +2 -0
  30. package/dist/sql/analyzer.d.ts.map +1 -1
  31. package/dist/sql/analyzer.test.d.ts +2 -0
  32. package/dist/sql/analyzer.test.d.ts.map +1 -0
  33. package/dist/sql/builder.test.d.ts +2 -0
  34. package/dist/sql/builder.test.d.ts.map +1 -0
  35. package/dist/sql/nudges.d.ts +1 -0
  36. package/dist/sql/nudges.d.ts.map +1 -1
  37. package/dist/sql/permutations.test.d.ts +2 -0
  38. package/dist/sql/permutations.test.d.ts.map +1 -0
  39. package/dist/sql/pg-identifier.test.d.ts +2 -0
  40. package/dist/sql/pg-identifier.test.d.ts.map +1 -0
  41. package/dist/sql/walker.d.ts +2 -1
  42. package/dist/sql/walker.d.ts.map +1 -1
  43. package/dist/sql/walker.test.d.ts +2 -0
  44. package/dist/sql/walker.test.d.ts.map +1 -0
  45. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -27,12 +27,25 @@ function isANode(node) {
27
27
  }
28
28
  function parseNudges(node, stack) {
29
29
  const nudges = [];
30
- if (is(node, "A_Star")) {
31
- nudges.push({
32
- kind: "AVOID_SELECT_STAR",
33
- severity: "INFO",
34
- message: "Avoid using SELECT *"
35
- });
30
+ if (is(node, "SelectStmt")) {
31
+ const star = node.SelectStmt.targetList?.find(
32
+ (target) => {
33
+ if (!(is(target, "ResTarget") && target.ResTarget.val && is(target.ResTarget.val, "ColumnRef"))) {
34
+ return false;
35
+ }
36
+ return target.ResTarget.val.ColumnRef.fields?.some(
37
+ (field) => is(field, "A_Star")
38
+ ) ?? false;
39
+ }
40
+ );
41
+ if (star) {
42
+ nudges.push({
43
+ kind: "AVOID_SELECT_STAR",
44
+ severity: "INFO",
45
+ message: "Avoid using SELECT *",
46
+ location: star.ResTarget.location
47
+ });
48
+ }
36
49
  }
37
50
  if (is(node, "FuncCall")) {
38
51
  const inWhereClause = stack.some((item) => item === "whereClause");
@@ -42,7 +55,8 @@ function parseNudges(node, stack) {
42
55
  nudges.push({
43
56
  kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
44
57
  severity: "WARNING",
45
- message: "Avoid using functions on columns in WHERE clause"
58
+ message: "Avoid using functions on columns in WHERE clause",
59
+ location: node.FuncCall.location
46
60
  });
47
61
  }
48
62
  }
@@ -58,18 +72,24 @@ function parseNudges(node, stack) {
58
72
  return is(fromItem, "RangeVar") || is(fromItem, "JoinExpr") && hasActualTablesInJoin(fromItem);
59
73
  });
60
74
  if (hasActualTables) {
75
+ const firstTable = node.SelectStmt.fromClause.find(
76
+ (item) => is(item, "RangeVar")
77
+ );
78
+ const fromLocation = firstTable?.RangeVar.location;
61
79
  if (!node.SelectStmt.whereClause) {
62
80
  nudges.push({
63
81
  kind: "MISSING_WHERE_CLAUSE",
64
82
  severity: "INFO",
65
- message: "Missing WHERE clause"
83
+ message: "Missing WHERE clause",
84
+ location: fromLocation
66
85
  });
67
86
  }
68
87
  if (!node.SelectStmt.limitCount) {
69
88
  nudges.push({
70
89
  kind: "MISSING_LIMIT_CLAUSE",
71
90
  severity: "INFO",
72
- message: "Missing LIMIT clause"
91
+ message: "Missing LIMIT clause",
92
+ location: fromLocation
73
93
  });
74
94
  }
75
95
  }
@@ -85,7 +105,8 @@ function parseNudges(node, stack) {
85
105
  nudges.push({
86
106
  kind: "USE_IS_NULL_NOT_EQUALS",
87
107
  severity: "WARNING",
88
- message: "Use IS NULL instead of = or != or <> for NULL comparisons"
108
+ message: "Use IS NULL instead of = or != or <> for NULL comparisons",
109
+ location: node.A_Expr.location
89
110
  });
90
111
  }
91
112
  }
@@ -93,10 +114,15 @@ function parseNudges(node, stack) {
93
114
  if (isLikeOp && node.A_Expr.rexpr) {
94
115
  const patternString = getStringConstantValue(node.A_Expr.rexpr);
95
116
  if (patternString && patternString.startsWith("%")) {
117
+ let stringNode;
118
+ if (is(node.A_Expr.rexpr, "A_Const")) {
119
+ stringNode = node.A_Expr.rexpr.A_Const;
120
+ }
96
121
  nudges.push({
97
122
  kind: "AVOID_LEADING_WILDCARD_LIKE",
98
123
  severity: "WARNING",
99
- message: "Avoid using LIKE with leading wildcards"
124
+ message: "Avoid using LIKE with leading wildcards",
125
+ location: stringNode?.location
100
126
  });
101
127
  }
102
128
  }
@@ -118,14 +144,15 @@ function parseNudges(node, stack) {
118
144
  }
119
145
  }
120
146
  if (is(node, "SelectStmt") && node.SelectStmt.fromClause && node.SelectStmt.fromClause.length > 1) {
121
- const tableCount = node.SelectStmt.fromClause.filter(
147
+ const tables = node.SelectStmt.fromClause.filter(
122
148
  (item) => is(item, "RangeVar")
123
- ).length;
124
- if (tableCount > 1) {
149
+ );
150
+ if (tables.length > 1) {
125
151
  nudges.push({
126
152
  kind: "MISSING_JOIN_CONDITION",
127
153
  severity: "WARNING",
128
- message: "Missing JOIN condition"
154
+ message: "Missing JOIN condition",
155
+ location: tables[1].RangeVar.location
129
156
  });
130
157
  }
131
158
  }
@@ -135,7 +162,8 @@ function parseNudges(node, stack) {
135
162
  nudges.push({
136
163
  kind: "CONSIDER_IN_INSTEAD_OF_MANY_ORS",
137
164
  severity: "WARNING",
138
- message: "Consider using IN instead of many ORs"
165
+ message: "Consider using IN instead of many ORs",
166
+ location: node.BoolExpr.location
139
167
  });
140
168
  }
141
169
  }
@@ -151,7 +179,8 @@ function parseNudges(node, stack) {
151
179
  nudges.push({
152
180
  kind: "REPLACE_LARGE_IN_TUPLE_WITH_ANY_ARRAY",
153
181
  message: "`in (...)` queries with large tuples can often be replaced with `= ANY($1)` using a single parameter",
154
- severity: "INFO"
182
+ severity: "INFO",
183
+ location: node.A_Expr.location
155
184
  });
156
185
  }
157
186
  }
@@ -265,7 +294,7 @@ var Walker = class _Walker {
265
294
  this.seenReferences = /* @__PURE__ */ new Map();
266
295
  this.shadowedAliases = [];
267
296
  this.nudges = [];
268
- _Walker.traverse(root, [], (node, stack) => {
297
+ _Walker.traverse(root, (node, stack) => {
269
298
  const nodeNudges = parseNudges(node, stack);
270
299
  this.nudges = [...this.nudges, ...nodeNudges];
271
300
  if (is2(node, "CommonTableExpr")) {
@@ -343,6 +372,18 @@ var Walker = class _Walker {
343
372
  }
344
373
  }
345
374
  }
375
+ if (is2(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP") {
376
+ const opName = node.A_Expr.name?.[0] && is2(node.A_Expr.name[0], "String") && node.A_Expr.name[0].String.sval;
377
+ if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&")) {
378
+ const jsonbOperator = opName;
379
+ if (node.A_Expr.lexpr && is2(node.A_Expr.lexpr, "ColumnRef")) {
380
+ this.add(node.A_Expr.lexpr, { jsonbOperator });
381
+ }
382
+ if (node.A_Expr.rexpr && is2(node.A_Expr.rexpr, "ColumnRef")) {
383
+ this.add(node.A_Expr.rexpr, { jsonbOperator });
384
+ }
385
+ }
386
+ }
346
387
  if (is2(node, "ColumnRef")) {
347
388
  for (let i = 0; i < stack.length; i++) {
348
389
  const inReturningList = stack[i] === "returningList" && stack[i + 1] === "ResTarget" && stack[i + 2] === "val" && stack[i + 3] === "ColumnRef";
@@ -441,9 +482,15 @@ var Walker = class _Walker {
441
482
  if (options?.where) {
442
483
  ref.where = options.where;
443
484
  }
485
+ if (options?.jsonbOperator) {
486
+ ref.jsonbOperator = options.jsonbOperator;
487
+ }
444
488
  this.highlights.push(ref);
445
489
  }
446
- static traverse(node, stack, callback) {
490
+ static traverse(node, callback) {
491
+ _Walker.doTraverse(node, [], callback);
492
+ }
493
+ static doTraverse(node, stack, callback) {
447
494
  if (isANode2(node)) {
448
495
  callback(node, [...stack, getNodeKind(node)]);
449
496
  }
@@ -453,15 +500,19 @@ var Walker = class _Walker {
453
500
  if (Array.isArray(node)) {
454
501
  for (const item of node) {
455
502
  if (isANode2(item)) {
456
- _Walker.traverse(item, stack, callback);
503
+ _Walker.doTraverse(item, stack, callback);
457
504
  }
458
505
  }
459
506
  } else if (isANode2(node)) {
460
507
  const keys = Object.keys(node);
461
- _Walker.traverse(node[keys[0]], [...stack, getNodeKind(node)], callback);
508
+ _Walker.doTraverse(node[keys[0]], [...stack, getNodeKind(node)], callback);
462
509
  } else {
463
510
  for (const [key, child] of Object.entries(node)) {
464
- _Walker.traverse(child, [...stack, key], callback);
511
+ _Walker.doTraverse(
512
+ child,
513
+ [...stack, key],
514
+ callback
515
+ );
465
516
  }
466
517
  }
467
518
  }
@@ -605,6 +656,9 @@ var Analyzer = class {
605
656
  if (colReference.where) {
606
657
  index.where = colReference.where;
607
658
  }
659
+ if (colReference.jsonbOperator) {
660
+ index.jsonbOperator = colReference.jsonbOperator;
661
+ }
608
662
  addIndex(index);
609
663
  }
610
664
  } else if (tableReference) {
@@ -626,6 +680,9 @@ var Analyzer = class {
626
680
  if (colReference.where) {
627
681
  index.where = colReference.where;
628
682
  }
683
+ if (colReference.jsonbOperator) {
684
+ index.jsonbOperator = colReference.jsonbOperator;
685
+ }
629
686
  addIndex(index);
630
687
  }
631
688
  } else if (fullReference) {
@@ -644,6 +701,9 @@ var Analyzer = class {
644
701
  if (colReference.where) {
645
702
  index.where = colReference.where;
646
703
  }
704
+ if (colReference.jsonbOperator) {
705
+ index.jsonbOperator = colReference.jsonbOperator;
706
+ }
647
707
  addIndex(index);
648
708
  } else {
649
709
  console.error(
@@ -766,7 +826,7 @@ async function dropIndex(tx, index) {
766
826
 
767
827
  // src/sql/indexes.ts
768
828
  function isIndexSupported(index) {
769
- return index.index_type === "btree";
829
+ return index.index_type === "btree" || index.index_type === "gin";
770
830
  }
771
831
  function isIndexProbablyDroppable(index) {
772
832
  return !index.is_primary && !index.is_unique;
@@ -1248,8 +1308,10 @@ var _IndexOptimizer = class _IndexOptimizer {
1248
1308
  * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1249
1309
  **/
1250
1310
  indexesToCreate(rootCandidates) {
1251
- const permutedIndexes = this.groupPotentialIndexColumnsByTable(rootCandidates);
1311
+ const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator);
1312
+ const ginCandidates = rootCandidates.filter((c) => c.jsonbOperator);
1252
1313
  const nextStage = [];
1314
+ const permutedIndexes = this.groupPotentialIndexColumnsByTable(btreeCandidates);
1253
1315
  for (const permutation of permutedIndexes.values()) {
1254
1316
  const { table: rawTable, schema: rawSchema, columns } = permutation;
1255
1317
  const permutations = permutationsWithDescendingLength(columns);
@@ -1274,6 +1336,41 @@ var _IndexOptimizer = class _IndexOptimizer {
1274
1336
  });
1275
1337
  }
1276
1338
  }
1339
+ const ginGroups = this.groupGinCandidatesByColumn(ginCandidates);
1340
+ for (const group of ginGroups.values()) {
1341
+ const { schema: rawSchema, table: rawTable, column, operators } = group;
1342
+ const schema = PgIdentifier.fromString(rawSchema);
1343
+ const table = PgIdentifier.fromString(rawTable);
1344
+ const needsKeyExistence = operators.some(
1345
+ (op) => op === "?" || op === "?|" || op === "?&"
1346
+ );
1347
+ const opclass = needsKeyExistence ? void 0 : "jsonb_path_ops";
1348
+ const existingGin = this.ginIndexAlreadyExists(table.toString(), column);
1349
+ if (existingGin) {
1350
+ continue;
1351
+ }
1352
+ const indexName = this.indexName();
1353
+ const candidate = {
1354
+ schema: rawSchema,
1355
+ table: rawTable,
1356
+ column
1357
+ };
1358
+ const definition = this.toGinDefinition({
1359
+ table,
1360
+ schema,
1361
+ column: PgIdentifier.fromString(column),
1362
+ opclass
1363
+ });
1364
+ nextStage.push({
1365
+ name: indexName,
1366
+ schema: schema.toString(),
1367
+ table: table.toString(),
1368
+ columns: [candidate],
1369
+ definition,
1370
+ indexMethod: "gin",
1371
+ opclass
1372
+ });
1373
+ }
1277
1374
  return nextStage;
1278
1375
  }
1279
1376
  toDefinition({
@@ -1308,6 +1405,47 @@ var _IndexOptimizer = class _IndexOptimizer {
1308
1405
  const colored = make(green, yellow, magenta, blue2);
1309
1406
  return { raw, colored };
1310
1407
  }
1408
+ toGinDefinition({
1409
+ schema,
1410
+ table,
1411
+ column,
1412
+ opclass
1413
+ }) {
1414
+ let fullyQualifiedTable;
1415
+ if (schema.toString() === "public") {
1416
+ fullyQualifiedTable = table;
1417
+ } else {
1418
+ fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1419
+ }
1420
+ const opclassSuffix = opclass ? ` ${opclass}` : "";
1421
+ return `${fullyQualifiedTable} using gin (${column}${opclassSuffix})`;
1422
+ }
1423
+ groupGinCandidatesByColumn(candidates) {
1424
+ const groups = /* @__PURE__ */ new Map();
1425
+ for (const c of candidates) {
1426
+ if (!c.jsonbOperator) continue;
1427
+ const key = `${c.schema}.${c.table}.${c.column}`;
1428
+ const existing = groups.get(key);
1429
+ if (existing) {
1430
+ if (!existing.operators.includes(c.jsonbOperator)) {
1431
+ existing.operators.push(c.jsonbOperator);
1432
+ }
1433
+ } else {
1434
+ groups.set(key, {
1435
+ schema: c.schema,
1436
+ table: c.table,
1437
+ column: c.column,
1438
+ operators: [c.jsonbOperator]
1439
+ });
1440
+ }
1441
+ }
1442
+ return groups;
1443
+ }
1444
+ ginIndexAlreadyExists(table, column) {
1445
+ return this.existingIndexes.find(
1446
+ (index) => index.index_type === "gin" && index.table_name === table && index.index_columns.some((c) => c.name === column)
1447
+ );
1448
+ }
1311
1449
  /**
1312
1450
  * Drop indexes that can be dropped. Ignore the ones that can't
1313
1451
  */
@@ -1624,6 +1762,21 @@ var _Statistics = class _Statistics {
1624
1762
  }
1625
1763
  return "text";
1626
1764
  }
1765
+ /**
1766
+ * PostgreSQL's anyarray columns in pg_statistic can hold arrays of arrays
1767
+ * for columns with array types (e.g. text[], int4[]). These create
1768
+ * multidimensional arrays that can be "ragged" (sub-arrays with different
1769
+ * lengths). jsonb_to_recordset can't reconstruct ragged multidimensional
1770
+ * arrays from JSON, so we need to drop these values.
1771
+ */
1772
+ static safeStavalues(values) {
1773
+ if (!values || values.length === 0) return values;
1774
+ if (values.some((v) => Array.isArray(v))) {
1775
+ console.warn("Discarding ragged multidimensional stavalues array");
1776
+ return null;
1777
+ }
1778
+ return values;
1779
+ }
1627
1780
  async restoreStats17(tx) {
1628
1781
  const warnings = {
1629
1782
  tablesNotInExports: [],
@@ -1675,16 +1828,26 @@ var _Statistics = class _Statistics {
1675
1828
  stanumbers3: stats.stanumbers3,
1676
1829
  stanumbers4: stats.stanumbers4,
1677
1830
  stanumbers5: stats.stanumbers5,
1678
- stavalues1: stats.stavalues1,
1679
- stavalues2: stats.stavalues2,
1680
- stavalues3: stats.stavalues3,
1681
- stavalues4: stats.stavalues4,
1682
- stavalues5: stats.stavalues5,
1683
- _value_type1: this.stavalueKind(stats.stavalues1),
1684
- _value_type2: this.stavalueKind(stats.stavalues2),
1685
- _value_type3: this.stavalueKind(stats.stavalues3),
1686
- _value_type4: this.stavalueKind(stats.stavalues4),
1687
- _value_type5: this.stavalueKind(stats.stavalues5)
1831
+ stavalues1: _Statistics.safeStavalues(stats.stavalues1),
1832
+ stavalues2: _Statistics.safeStavalues(stats.stavalues2),
1833
+ stavalues3: _Statistics.safeStavalues(stats.stavalues3),
1834
+ stavalues4: _Statistics.safeStavalues(stats.stavalues4),
1835
+ stavalues5: _Statistics.safeStavalues(stats.stavalues5),
1836
+ _value_type1: this.stavalueKind(
1837
+ _Statistics.safeStavalues(stats.stavalues1)
1838
+ ),
1839
+ _value_type2: this.stavalueKind(
1840
+ _Statistics.safeStavalues(stats.stavalues2)
1841
+ ),
1842
+ _value_type3: this.stavalueKind(
1843
+ _Statistics.safeStavalues(stats.stavalues3)
1844
+ ),
1845
+ _value_type4: this.stavalueKind(
1846
+ _Statistics.safeStavalues(stats.stavalues4)
1847
+ ),
1848
+ _value_type5: this.stavalueKind(
1849
+ _Statistics.safeStavalues(stats.stavalues5)
1850
+ )
1688
1851
  });
1689
1852
  }
1690
1853
  }
@@ -2100,14 +2263,16 @@ var _Statistics = class _Statistics {
2100
2263
  CASE
2101
2264
  WHEN (indoption[array_position(ix.indkey, a.attnum)] & 1) = 1 THEN 'DESC'
2102
2265
  ELSE 'ASC'
2103
- END)
2266
+ END,
2267
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2104
2268
  -- Handle expressions
2105
2269
  ELSE
2106
2270
  json_build_object('name', pg_get_expr((ix.indexprs)::pg_node_tree, t.oid), 'order',
2107
2271
  CASE
2108
2272
  WHEN (indoption[array_position(ix.indkey, k.attnum)] & 1) = 1 THEN 'DESC'
2109
2273
  ELSE 'ASC'
2110
- END)
2274
+ END,
2275
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2111
2276
  END
2112
2277
  ORDER BY array_position(ix.indkey, k.attnum)
2113
2278
  ) AS index_columns
@@ -2119,6 +2284,7 @@ var _Statistics = class _Statistics {
2119
2284
  JOIN pg_am am ON i.relam = am.oid
2120
2285
  LEFT JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY k(attnum, ordinality) ON true
2121
2286
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
2287
+ LEFT JOIN pg_opclass opc ON opc.oid = ix.indclass[k.ordinality - 1]
2122
2288
  JOIN pg_namespace n ON t.relnamespace = n.oid
2123
2289
  WHERE
2124
2290
  n.nspname not like 'pg_%' and