@query-doctor/core 0.4.1-rc.2 → 0.4.1-rc.4

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
@@ -56,7 +56,7 @@ const SPATIAL_FUNCTIONS = new Set([
56
56
  "st_overlaps",
57
57
  "st_touches"
58
58
  ]);
59
- const JSONB_EXTRACTION_OPS = new Set([
59
+ const JSONB_EXTRACTION_OPS$1 = new Set([
60
60
  "->>",
61
61
  "->",
62
62
  "#>>",
@@ -74,7 +74,7 @@ function getAExprOpName(node) {
74
74
  function findExtractionExpr(node) {
75
75
  if (is$1(node, "A_Expr")) {
76
76
  const op = getAExprOpName(node);
77
- if (op && JSONB_EXTRACTION_OPS.has(op)) {
77
+ if (op && JSONB_EXTRACTION_OPS$1.has(op)) {
78
78
  if (node.A_Expr.lexpr && hasColumnRefInNode(node.A_Expr.lexpr)) return node;
79
79
  return null;
80
80
  }
@@ -144,7 +144,7 @@ const findJsonbExtractionInWhere = (whereClause) => {
144
144
  function walk(node) {
145
145
  if (is$1(node, "A_Expr")) {
146
146
  const op = getAExprOpName(node);
147
- if (op && JSONB_EXTRACTION_OPS.has(op)) return;
147
+ if (op && JSONB_EXTRACTION_OPS$1.has(op)) return;
148
148
  if (node.A_Expr.lexpr) emit(node.A_Expr.lexpr);
149
149
  if (node.A_Expr.rexpr) emit(node.A_Expr.rexpr);
150
150
  return;
@@ -400,6 +400,17 @@ function parseNudges(node, stack) {
400
400
  location: node.FuncCall.location
401
401
  });
402
402
  }
403
+ if (is$1(node, "FuncCall")) {
404
+ if (stack.some((item) => item === "whereClause") && node.FuncCall.args) {
405
+ const name = getFuncName(node);
406
+ if (name && JSONB_SET_RETURNING_FUNCTIONS.has(name) && containsColumnRef(node.FuncCall.args)) nudges.push({
407
+ kind: "CONSIDER_JSONB_CONTAINMENT_OPERATOR",
408
+ severity: "INFO",
409
+ 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.",
410
+ location: node.FuncCall.location
411
+ });
412
+ }
413
+ }
403
414
  if (is$1(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0) {
404
415
  const opNode = node.A_Expr.name[0];
405
416
  const op = is$1(opNode, "String") ? opNode.String.sval : null;
@@ -567,6 +578,23 @@ function containsAggregate(node) {
567
578
  for (const child of Object.values(node)) if (containsAggregate(child)) return true;
568
579
  return false;
569
580
  }
581
+ const JSONB_SET_RETURNING_FUNCTIONS = new Set([
582
+ "jsonb_array_elements",
583
+ "json_array_elements",
584
+ "jsonb_array_elements_text",
585
+ "json_array_elements_text",
586
+ "jsonb_each",
587
+ "json_each",
588
+ "jsonb_each_text",
589
+ "json_each_text"
590
+ ]);
591
+ function getFuncName(node) {
592
+ const names = node.FuncCall.funcname;
593
+ if (!names || names.length === 0) return null;
594
+ const last = names[names.length - 1];
595
+ if (isANode$1(last) && is$1(last, "String") && last.String.sval) return last.String.sval;
596
+ return null;
597
+ }
570
598
  function getLastColumnRefField(columnRef) {
571
599
  const fields = columnRef.ColumnRef.fields;
572
600
  if (!fields || fields.length === 0) return null;
@@ -752,6 +780,20 @@ function _defineProperty(e, r, t) {
752
780
 
753
781
  //#endregion
754
782
  //#region src/sql/walker.ts
783
+ const JSONB_EXTRACTION_OPS = new Set(["->", "->>"]);
784
+ const COMPARISON_OPS = new Set([
785
+ "=",
786
+ "<>",
787
+ "!=",
788
+ "<",
789
+ "<=",
790
+ ">",
791
+ ">=",
792
+ "~~",
793
+ "~~*",
794
+ "!~~",
795
+ "!~~*"
796
+ ]);
755
797
  /**
756
798
  * Walks the AST of a sql query and extracts query metadata.
757
799
  * This pattern is used to segregate the mutable state that's more common for the
@@ -832,7 +874,7 @@ var Walker = class Walker {
832
874
  }
833
875
  if (is(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP") {
834
876
  const opName = node.A_Expr.name?.[0] && is(node.A_Expr.name[0], "String") && node.A_Expr.name[0].String.sval;
835
- if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&")) {
877
+ if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&" || opName === "@@" || opName === "@?")) {
836
878
  const jsonbOperator = opName;
837
879
  if (node.A_Expr.lexpr && is(node.A_Expr.lexpr, "ColumnRef")) this.add(node.A_Expr.lexpr, { jsonbOperator });
838
880
  if (node.A_Expr.rexpr && is(node.A_Expr.rexpr, "ColumnRef")) this.add(node.A_Expr.rexpr, { jsonbOperator });
@@ -842,6 +884,11 @@ var Walker = class Walker {
842
884
  if (node.A_Expr.lexpr && is(node.A_Expr.lexpr, "ColumnRef")) this.add(node.A_Expr.lexpr, { gistOperator });
843
885
  if (node.A_Expr.rexpr && is(node.A_Expr.rexpr, "ColumnRef")) this.add(node.A_Expr.rexpr, { gistOperator });
844
886
  }
887
+ if (opName && COMPARISON_OPS.has(opName)) for (const operand of [node.A_Expr.lexpr, node.A_Expr.rexpr]) {
888
+ if (!operand) continue;
889
+ const extraction = extractJsonbPath(operand);
890
+ if (extraction) this.add(extraction.columnRef, { jsonbExtraction: extraction.expression });
891
+ }
845
892
  }
846
893
  if (is(node, "ColumnRef")) {
847
894
  for (let i = 0; i < stack.length; i++) {
@@ -926,6 +973,7 @@ var Walker = class Walker {
926
973
  if (options?.where) ref.where = options.where;
927
974
  if (options?.jsonbOperator) ref.jsonbOperator = options.jsonbOperator;
928
975
  if (options?.gistOperator) ref.gistOperator = options.gistOperator;
976
+ if (options?.jsonbExtraction) ref.jsonbExtraction = options.jsonbExtraction;
929
977
  this.highlights.push(ref);
930
978
  }
931
979
  /**
@@ -993,6 +1041,57 @@ function isANode(node) {
993
1041
  const keys = Object.keys(node);
994
1042
  return keys.length === 1 && /^[A-Z]/.test(keys[0]);
995
1043
  }
1044
+ /**
1045
+ * Given an operand of a comparison (e.g. the left side of `=`), check whether
1046
+ * it is a JSONB path extraction expression such as `data->>'email'` or
1047
+ * `(data->>'age')::int`. If so, return the root ColumnRef (for the walker to
1048
+ * register) and the full expression string (for use in the expression index).
1049
+ *
1050
+ * Handles:
1051
+ * - `data->>'email'` — simple extraction
1052
+ * - `(data->>'age')::int` — extraction with cast
1053
+ * - `data->'addr'->>'city'` — chained extraction
1054
+ * - `t.data->>'email'` — table-qualified (strips qualifier from expression)
1055
+ */
1056
+ function extractJsonbPath(node) {
1057
+ let exprNode = node;
1058
+ if (is(exprNode, "TypeCast") && exprNode.TypeCast.arg) exprNode = exprNode.TypeCast.arg;
1059
+ if (!is(exprNode, "A_Expr") || exprNode.A_Expr.kind !== "AEXPR_OP") return;
1060
+ const innerOp = exprNode.A_Expr.name?.[0] && is(exprNode.A_Expr.name[0], "String") && exprNode.A_Expr.name[0].String.sval;
1061
+ if (!innerOp || !JSONB_EXTRACTION_OPS.has(innerOp)) return;
1062
+ const rootCol = findRootColumnRef(exprNode);
1063
+ if (!rootCol) return;
1064
+ const cloned = JSON.parse(JSON.stringify(node));
1065
+ stripTableQualifiers(cloned);
1066
+ return {
1067
+ columnRef: rootCol,
1068
+ expression: (0, pgsql_deparser.deparseSync)(cloned)
1069
+ };
1070
+ }
1071
+ /**
1072
+ * Walk the left side of a chain of `->` / `->>` operators to find the
1073
+ * root ColumnRef (the JSONB column itself).
1074
+ */
1075
+ function findRootColumnRef(node) {
1076
+ if (is(node, "ColumnRef")) return node;
1077
+ if (is(node, "A_Expr") && node.A_Expr.lexpr) return findRootColumnRef(node.A_Expr.lexpr);
1078
+ if (is(node, "TypeCast") && node.TypeCast.arg) return findRootColumnRef(node.TypeCast.arg);
1079
+ }
1080
+ /**
1081
+ * Remove table/schema qualifiers from ColumnRef nodes inside the
1082
+ * expression so the deparsed expression contains only the column name.
1083
+ * e.g. `t.data->>'email'` → `data->>'email'`
1084
+ */
1085
+ function stripTableQualifiers(node) {
1086
+ if (typeof node !== "object" || node === null) return;
1087
+ if ("ColumnRef" in node && node.ColumnRef?.fields) {
1088
+ const fields = node.ColumnRef.fields;
1089
+ if (fields.length > 1) node.ColumnRef.fields = [fields[fields.length - 1]];
1090
+ return;
1091
+ }
1092
+ for (const value of Object.values(node)) if (Array.isArray(value)) for (const item of value) stripTableQualifiers(item);
1093
+ else if (typeof value === "object" && value !== null) stripTableQualifiers(value);
1094
+ }
996
1095
 
997
1096
  //#endregion
998
1097
  //#region src/sql/analyzer.ts
@@ -1034,10 +1133,11 @@ var Analyzer = class {
1034
1133
  const queryBeforeMatch = currQuery.slice(0, highlight.position.start);
1035
1134
  const queryAfterToken = currQuery.slice(highlight.position.end);
1036
1135
  currQuery = `${queryBeforeMatch}${color(queryRepr)}${this.colorizeKeywords(queryAfterToken, color)}`;
1037
- if (indexRepresentations.has(queryRepr)) skip = true;
1136
+ const reprKey = highlight.jsonbExtraction ? `${queryRepr}::${highlight.jsonbExtraction}` : queryRepr;
1137
+ if (indexRepresentations.has(reprKey)) skip = true;
1038
1138
  if (!skip) {
1039
1139
  indexesToCheck.push(highlight);
1040
- indexRepresentations.add(queryRepr);
1140
+ indexRepresentations.add(reprKey);
1041
1141
  }
1042
1142
  }
1043
1143
  const referencedTables = [];
@@ -1070,7 +1170,8 @@ var Analyzer = class {
1070
1170
  const allIndexes = [];
1071
1171
  const seenIndexes = /* @__PURE__ */ new Set();
1072
1172
  function addIndex(index) {
1073
- const key = `"${index.schema}":"${index.table}":"${index.column}"`;
1173
+ const extractionSuffix = index.jsonbExtraction ? `:"${index.jsonbExtraction}"` : "";
1174
+ const key = `"${index.schema}":"${index.table}":"${index.column}"${extractionSuffix}`;
1074
1175
  if (seenIndexes.has(key)) return;
1075
1176
  seenIndexes.add(key);
1076
1177
  allIndexes.push(index);
@@ -1095,6 +1196,7 @@ var Analyzer = class {
1095
1196
  if (colReference.where) index.where = colReference.where;
1096
1197
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
1097
1198
  if (colReference.gistOperator) index.gistOperator = colReference.gistOperator;
1199
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
1098
1200
  addIndex(index);
1099
1201
  }
1100
1202
  } else if (tableReference) {
@@ -1112,6 +1214,7 @@ var Analyzer = class {
1112
1214
  if (colReference.where) index.where = colReference.where;
1113
1215
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
1114
1216
  if (colReference.gistOperator) index.gistOperator = colReference.gistOperator;
1217
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
1115
1218
  addIndex(index);
1116
1219
  }
1117
1220
  } else if (fullReference) {
@@ -1125,6 +1228,7 @@ var Analyzer = class {
1125
1228
  if (colReference.where) index.where = colReference.where;
1126
1229
  if (colReference.jsonbOperator) index.jsonbOperator = colReference.jsonbOperator;
1127
1230
  if (colReference.gistOperator) index.gistOperator = colReference.gistOperator;
1231
+ if (colReference.jsonbExtraction) index.jsonbExtraction = colReference.jsonbExtraction;
1128
1232
  addIndex(index);
1129
1233
  } else {
1130
1234
  console.error("Column reference has too many parts. The query is malformed", colReference);
@@ -1670,7 +1774,8 @@ var IndexOptimizer = class IndexOptimizer {
1670
1774
  * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1671
1775
  **/
1672
1776
  indexesToCreate(rootCandidates) {
1673
- const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator && !c.gistOperator);
1777
+ const expressionCandidates = rootCandidates.filter((c) => c.jsonbExtraction);
1778
+ const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator && !c.gistOperator && !c.jsonbExtraction);
1674
1779
  const ginCandidates = rootCandidates.filter((c) => c.jsonbOperator);
1675
1780
  const gistCandidates = rootCandidates.filter((c) => c.gistOperator);
1676
1781
  const nextStage = [];
@@ -1702,7 +1807,7 @@ var IndexOptimizer = class IndexOptimizer {
1702
1807
  const { schema: rawSchema, table: rawTable, column, operators } = group;
1703
1808
  const schema = PgIdentifier.fromString(rawSchema);
1704
1809
  const table = PgIdentifier.fromString(rawTable);
1705
- const opclass = operators.some((op) => op === "?" || op === "?|" || op === "?&") ? void 0 : "jsonb_path_ops";
1810
+ const opclass = operators.some((op) => op !== "@>") ? void 0 : "jsonb_path_ops";
1706
1811
  if (this.ginIndexAlreadyExists(table.toString(), column)) continue;
1707
1812
  const indexName = this.indexName();
1708
1813
  const candidate = {
@@ -1752,6 +1857,28 @@ var IndexOptimizer = class IndexOptimizer {
1752
1857
  indexMethod: "gist"
1753
1858
  });
1754
1859
  }
1860
+ const seenExpressions = /* @__PURE__ */ new Set();
1861
+ for (const candidate of expressionCandidates) {
1862
+ const expression = candidate.jsonbExtraction;
1863
+ const key = `${candidate.schema}.${candidate.table}.${expression}`;
1864
+ if (seenExpressions.has(key)) continue;
1865
+ seenExpressions.add(key);
1866
+ const schema = PgIdentifier.fromString(candidate.schema);
1867
+ const table = PgIdentifier.fromString(candidate.table);
1868
+ const indexName = this.indexName();
1869
+ const definition = this.toExpressionDefinition({
1870
+ table,
1871
+ schema,
1872
+ expression
1873
+ });
1874
+ nextStage.push({
1875
+ name: indexName,
1876
+ schema: schema.toString(),
1877
+ table: table.toString(),
1878
+ columns: [candidate],
1879
+ definition
1880
+ });
1881
+ }
1755
1882
  return nextStage;
1756
1883
  }
1757
1884
  toDefinition({ schema, table, columns }) {
@@ -1788,6 +1915,12 @@ var IndexOptimizer = class IndexOptimizer {
1788
1915
  else fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1789
1916
  return `${fullyQualifiedTable} using gist (${column})`;
1790
1917
  }
1918
+ toExpressionDefinition({ schema, table, expression }) {
1919
+ let fullyQualifiedTable;
1920
+ if (schema.toString() === "public") fullyQualifiedTable = table;
1921
+ else fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1922
+ return `${fullyQualifiedTable}((${expression}))`;
1923
+ }
1791
1924
  groupGinCandidatesByColumn(candidates) {
1792
1925
  const groups = /* @__PURE__ */ new Map();
1793
1926
  for (const c of candidates) {