@query-doctor/core 0.5.0 → 0.6.0

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/dist/index.cjs CHANGED
@@ -229,6 +229,17 @@ function parseNudges(node, stack) {
229
229
  });
230
230
  }
231
231
  }
232
+ if (is$1(node, "FuncCall")) {
233
+ if (stack.some((item) => item === "whereClause") && node.FuncCall.args) {
234
+ const name = getFuncName(node);
235
+ if (name && JSONB_SET_RETURNING_FUNCTIONS.has(name) && containsColumnRef(node.FuncCall.args)) nudges.push({
236
+ kind: "CONSIDER_JSONB_CONTAINMENT_OPERATOR",
237
+ severity: "INFO",
238
+ message: "JSONB set-returning functions (e.g. jsonb_array_elements) cannot be used as an index access path. If the query checks for containment or key existence, GIN-compatible operators (@>, ?, ?|, ?&, @?, @@) may allow index usage.",
239
+ location: node.FuncCall.location
240
+ });
241
+ }
242
+ }
232
243
  if (is$1(node, "A_Expr")) {
233
244
  if (node.A_Expr.kind === "AEXPR_IN") {
234
245
  let list;
@@ -316,6 +327,23 @@ function getStringConstantValue(node) {
316
327
  if (isANode$1(node) && is$1(node, "A_Const") && node.A_Const.sval) return node.A_Const.sval.sval || null;
317
328
  return null;
318
329
  }
330
+ const JSONB_SET_RETURNING_FUNCTIONS = new Set([
331
+ "jsonb_array_elements",
332
+ "json_array_elements",
333
+ "jsonb_array_elements_text",
334
+ "json_array_elements_text",
335
+ "jsonb_each",
336
+ "json_each",
337
+ "jsonb_each_text",
338
+ "json_each_text"
339
+ ]);
340
+ function getFuncName(node) {
341
+ const names = node.FuncCall.funcname;
342
+ if (!names || names.length === 0) return null;
343
+ const last = names[names.length - 1];
344
+ if (isANode$1(last) && is$1(last, "String") && last.String.sval) return last.String.sval;
345
+ return null;
346
+ }
319
347
  function getLastColumnRefField(columnRef) {
320
348
  const fields = columnRef.ColumnRef.fields;
321
349
  if (!fields || fields.length === 0) return null;
@@ -458,6 +486,20 @@ function _defineProperty(e, r, t) {
458
486
 
459
487
  //#endregion
460
488
  //#region src/sql/walker.ts
489
+ const JSONB_EXTRACTION_OPS = new Set(["->", "->>"]);
490
+ const COMPARISON_OPS = new Set([
491
+ "=",
492
+ "<>",
493
+ "!=",
494
+ "<",
495
+ "<=",
496
+ ">",
497
+ ">=",
498
+ "~~",
499
+ "~~*",
500
+ "!~~",
501
+ "!~~*"
502
+ ]);
461
503
  /**
462
504
  * Walks the AST of a sql query and extracts query metadata.
463
505
  * This pattern is used to segregate the mutable state that's more common for the
@@ -538,11 +580,16 @@ var Walker = class Walker {
538
580
  }
539
581
  if (is(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP") {
540
582
  const opName = node.A_Expr.name?.[0] && is(node.A_Expr.name[0], "String") && node.A_Expr.name[0].String.sval;
541
- if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&")) {
583
+ if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&" || opName === "@@" || opName === "@?")) {
542
584
  const jsonbOperator = opName;
543
585
  if (node.A_Expr.lexpr && is(node.A_Expr.lexpr, "ColumnRef")) this.add(node.A_Expr.lexpr, { jsonbOperator });
544
586
  if (node.A_Expr.rexpr && is(node.A_Expr.rexpr, "ColumnRef")) this.add(node.A_Expr.rexpr, { jsonbOperator });
545
587
  }
588
+ if (opName && COMPARISON_OPS.has(opName)) for (const operand of [node.A_Expr.lexpr, node.A_Expr.rexpr]) {
589
+ if (!operand) continue;
590
+ const extraction = extractJsonbPath(operand);
591
+ if (extraction) this.add(extraction.columnRef, { jsonbExtraction: extraction.expression });
592
+ }
546
593
  }
547
594
  if (is(node, "ColumnRef")) {
548
595
  for (let i = 0; i < stack.length; i++) {
@@ -626,6 +673,7 @@ var Walker = class Walker {
626
673
  if (options?.sort) ref.sort = options.sort;
627
674
  if (options?.where) ref.where = options.where;
628
675
  if (options?.jsonbOperator) ref.jsonbOperator = options.jsonbOperator;
676
+ if (options?.jsonbExtraction) ref.jsonbExtraction = options.jsonbExtraction;
629
677
  this.highlights.push(ref);
630
678
  }
631
679
  /**
@@ -693,6 +741,57 @@ function isANode(node) {
693
741
  const keys = Object.keys(node);
694
742
  return keys.length === 1 && /^[A-Z]/.test(keys[0]);
695
743
  }
744
+ /**
745
+ * Given an operand of a comparison (e.g. the left side of `=`), check whether
746
+ * it is a JSONB path extraction expression such as `data->>'email'` or
747
+ * `(data->>'age')::int`. If so, return the root ColumnRef (for the walker to
748
+ * register) and the full expression string (for use in the expression index).
749
+ *
750
+ * Handles:
751
+ * - `data->>'email'` — simple extraction
752
+ * - `(data->>'age')::int` — extraction with cast
753
+ * - `data->'addr'->>'city'` — chained extraction
754
+ * - `t.data->>'email'` — table-qualified (strips qualifier from expression)
755
+ */
756
+ function extractJsonbPath(node) {
757
+ let exprNode = node;
758
+ if (is(exprNode, "TypeCast") && exprNode.TypeCast.arg) exprNode = exprNode.TypeCast.arg;
759
+ if (!is(exprNode, "A_Expr") || exprNode.A_Expr.kind !== "AEXPR_OP") return;
760
+ const innerOp = exprNode.A_Expr.name?.[0] && is(exprNode.A_Expr.name[0], "String") && exprNode.A_Expr.name[0].String.sval;
761
+ if (!innerOp || !JSONB_EXTRACTION_OPS.has(innerOp)) return;
762
+ const rootCol = findRootColumnRef(exprNode);
763
+ if (!rootCol) return;
764
+ const cloned = JSON.parse(JSON.stringify(node));
765
+ stripTableQualifiers(cloned);
766
+ return {
767
+ columnRef: rootCol,
768
+ expression: (0, pgsql_deparser.deparseSync)(cloned)
769
+ };
770
+ }
771
+ /**
772
+ * Walk the left side of a chain of `->` / `->>` operators to find the
773
+ * root ColumnRef (the JSONB column itself).
774
+ */
775
+ function findRootColumnRef(node) {
776
+ if (is(node, "ColumnRef")) return node;
777
+ if (is(node, "A_Expr") && node.A_Expr.lexpr) return findRootColumnRef(node.A_Expr.lexpr);
778
+ if (is(node, "TypeCast") && node.TypeCast.arg) return findRootColumnRef(node.TypeCast.arg);
779
+ }
780
+ /**
781
+ * Remove table/schema qualifiers from ColumnRef nodes inside the
782
+ * expression so the deparsed expression contains only the column name.
783
+ * e.g. `t.data->>'email'` → `data->>'email'`
784
+ */
785
+ function stripTableQualifiers(node) {
786
+ if (typeof node !== "object" || node === null) return;
787
+ if ("ColumnRef" in node && node.ColumnRef?.fields) {
788
+ const fields = node.ColumnRef.fields;
789
+ if (fields.length > 1) node.ColumnRef.fields = [fields[fields.length - 1]];
790
+ return;
791
+ }
792
+ for (const value of Object.values(node)) if (Array.isArray(value)) for (const item of value) stripTableQualifiers(item);
793
+ else if (typeof value === "object" && value !== null) stripTableQualifiers(value);
794
+ }
696
795
 
697
796
  //#endregion
698
797
  //#region src/sql/analyzer.ts
@@ -734,10 +833,11 @@ var Analyzer = class {
734
833
  const queryBeforeMatch = currQuery.slice(0, highlight.position.start);
735
834
  const queryAfterToken = currQuery.slice(highlight.position.end);
736
835
  currQuery = `${queryBeforeMatch}${color(queryRepr)}${this.colorizeKeywords(queryAfterToken, color)}`;
737
- if (indexRepresentations.has(queryRepr)) skip = true;
836
+ const reprKey = highlight.jsonbExtraction ? `${queryRepr}::${highlight.jsonbExtraction}` : queryRepr;
837
+ if (indexRepresentations.has(reprKey)) skip = true;
738
838
  if (!skip) {
739
839
  indexesToCheck.push(highlight);
740
- indexRepresentations.add(queryRepr);
840
+ indexRepresentations.add(reprKey);
741
841
  }
742
842
  }
743
843
  const referencedTables = [];
@@ -770,7 +870,8 @@ var Analyzer = class {
770
870
  const allIndexes = [];
771
871
  const seenIndexes = /* @__PURE__ */ new Set();
772
872
  function addIndex(index) {
773
- const key = `"${index.schema}":"${index.table}":"${index.column}"`;
873
+ const extractionSuffix = index.jsonbExtraction ? `:"${index.jsonbExtraction}"` : "";
874
+ const key = `"${index.schema}":"${index.table}":"${index.column}"${extractionSuffix}`;
774
875
  if (seenIndexes.has(key)) return;
775
876
  seenIndexes.add(key);
776
877
  allIndexes.push(index);
@@ -794,6 +895,7 @@ var Analyzer = class {
794
895
  if (colReference.sort) index.sort = colReference.sort;
795
896
  if (colReference.where) index.where = colReference.where;
796
897
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
898
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
797
899
  addIndex(index);
798
900
  }
799
901
  } else if (tableReference) {
@@ -810,6 +912,7 @@ var Analyzer = class {
810
912
  if (colReference.sort) index.sort = colReference.sort;
811
913
  if (colReference.where) index.where = colReference.where;
812
914
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
915
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
813
916
  addIndex(index);
814
917
  }
815
918
  } else if (fullReference) {
@@ -822,6 +925,7 @@ var Analyzer = class {
822
925
  if (colReference.sort) index.sort = colReference.sort;
823
926
  if (colReference.where) index.where = colReference.where;
824
927
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
928
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
825
929
  addIndex(index);
826
930
  } else {
827
931
  console.error("Column reference has too many parts. The query is malformed", colReference);
@@ -1372,7 +1476,8 @@ var IndexOptimizer = class IndexOptimizer {
1372
1476
  * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1373
1477
  **/
1374
1478
  indexesToCreate(rootCandidates) {
1375
- const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator);
1479
+ const expressionCandidates = rootCandidates.filter((c) => c.jsonbExtraction);
1480
+ const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator && !c.jsonbExtraction);
1376
1481
  const ginCandidates = rootCandidates.filter((c) => c.jsonbOperator);
1377
1482
  const nextStage = [];
1378
1483
  const permutedIndexes = this.groupPotentialIndexColumnsByTable(btreeCandidates);
@@ -1403,7 +1508,7 @@ var IndexOptimizer = class IndexOptimizer {
1403
1508
  const { schema: rawSchema, table: rawTable, column, operators } = group;
1404
1509
  const schema = PgIdentifier.fromString(rawSchema);
1405
1510
  const table = PgIdentifier.fromString(rawTable);
1406
- const opclass = operators.some((op) => op === "?" || op === "?|" || op === "?&") ? void 0 : "jsonb_path_ops";
1511
+ const opclass = operators.some((op) => op !== "@>") ? void 0 : "jsonb_path_ops";
1407
1512
  if (this.ginIndexAlreadyExists(table.toString(), column)) continue;
1408
1513
  const indexName = this.indexName();
1409
1514
  const candidate = {
@@ -1427,6 +1532,28 @@ var IndexOptimizer = class IndexOptimizer {
1427
1532
  opclass
1428
1533
  });
1429
1534
  }
1535
+ const seenExpressions = /* @__PURE__ */ new Set();
1536
+ for (const candidate of expressionCandidates) {
1537
+ const expression = candidate.jsonbExtraction;
1538
+ const key = `${candidate.schema}.${candidate.table}.${expression}`;
1539
+ if (seenExpressions.has(key)) continue;
1540
+ seenExpressions.add(key);
1541
+ const schema = PgIdentifier.fromString(candidate.schema);
1542
+ const table = PgIdentifier.fromString(candidate.table);
1543
+ const indexName = this.indexName();
1544
+ const definition = this.toExpressionDefinition({
1545
+ table,
1546
+ schema,
1547
+ expression
1548
+ });
1549
+ nextStage.push({
1550
+ name: indexName,
1551
+ schema: schema.toString(),
1552
+ table: table.toString(),
1553
+ columns: [candidate],
1554
+ definition
1555
+ });
1556
+ }
1430
1557
  return nextStage;
1431
1558
  }
1432
1559
  toDefinition({ schema, table, columns }) {
@@ -1457,6 +1584,12 @@ var IndexOptimizer = class IndexOptimizer {
1457
1584
  const opclassSuffix = opclass ? ` ${opclass}` : "";
1458
1585
  return `${fullyQualifiedTable} using gin (${column}${opclassSuffix})`;
1459
1586
  }
1587
+ toExpressionDefinition({ schema, table, expression }) {
1588
+ let fullyQualifiedTable;
1589
+ if (schema.toString() === "public") fullyQualifiedTable = table;
1590
+ else fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1591
+ return `${fullyQualifiedTable}((${expression}))`;
1592
+ }
1460
1593
  groupGinCandidatesByColumn(candidates) {
1461
1594
  const groups = /* @__PURE__ */ new Map();
1462
1595
  for (const c of candidates) {
@@ -2116,6 +2249,7 @@ var Statistics = class Statistics {
2116
2249
  ON n.oid = cl.relnamespace
2117
2250
  WHERE c.table_name NOT LIKE 'pg_%'
2118
2251
  AND n.nspname <> 'information_schema'
2252
+ AND n.nspname NOT IN ('tiger', 'tiger_data', 'topology')
2119
2253
  AND c.table_name NOT IN ('pg_stat_statements', 'pg_stat_statements_info')
2120
2254
  GROUP BY c.table_name, c.table_schema, cl.reltuples, cl.relpages, cl.relallvisible, n.nspname
2121
2255
  ),
@@ -2137,6 +2271,7 @@ var Statistics = class Statistics {
2137
2271
  JOIN pg_namespace n ON n.oid = t.relnamespace
2138
2272
  WHERE t.relname NOT LIKE 'pg_%'
2139
2273
  AND n.nspname <> 'information_schema'
2274
+ AND n.nspname NOT IN ('tiger', 'tiger_data', 'topology')
2140
2275
  GROUP BY t.relname
2141
2276
  )
2142
2277
  SELECT json_agg(