@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 +142 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -2
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +5 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +142 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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) {
|