@query-doctor/core 0.5.0-rc.4 → 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 +141 -6
- 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 +141 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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(
|