@query-doctor/core 0.4.2 → 0.5.0-rc.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.
- package/dist/index.cjs +196 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +196 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -52,6 +52,14 @@ const findFuncCallsOnColumns = (whereClause) => {
|
|
|
52
52
|
location: node.FuncCall.location
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
Walker.shallowMatch(whereClause, "CoalesceExpr", (node) => {
|
|
56
|
+
if (node.CoalesceExpr.args && containsColumnRef(node.CoalesceExpr.args)) nudges.push({
|
|
57
|
+
kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
|
|
58
|
+
severity: "WARNING",
|
|
59
|
+
message: "Avoid using functions on columns in WHERE clause",
|
|
60
|
+
location: node.CoalesceExpr.location
|
|
61
|
+
});
|
|
62
|
+
});
|
|
55
63
|
return nudges;
|
|
56
64
|
};
|
|
57
65
|
/**
|
|
@@ -78,6 +86,12 @@ function parseNudges(node, stack) {
|
|
|
78
86
|
location: star.ResTarget.location
|
|
79
87
|
});
|
|
80
88
|
}
|
|
89
|
+
for (const target of node.SelectStmt.targetList ?? []) if (is$1(target, "ResTarget") && target.ResTarget.val && is$1(target.ResTarget.val, "SubLink") && target.ResTarget.val.SubLink.subLinkType === "EXPR_SUBLINK") nudges.push({
|
|
90
|
+
kind: "AVOID_SCALAR_SUBQUERY_IN_SELECT",
|
|
91
|
+
severity: "WARNING",
|
|
92
|
+
message: "Avoid correlated scalar subqueries in SELECT; consider rewriting as a JOIN",
|
|
93
|
+
location: target.ResTarget.val.SubLink.location
|
|
94
|
+
});
|
|
81
95
|
}
|
|
82
96
|
if (is$1(node, "SelectStmt")) {
|
|
83
97
|
if (!stack.some((item) => item === "RangeSubselect" || item === "SubLink" || item === "CommonTableExpr")) {
|
|
@@ -102,6 +116,22 @@ function parseNudges(node, stack) {
|
|
|
102
116
|
}
|
|
103
117
|
}
|
|
104
118
|
}
|
|
119
|
+
if (is$1(node, "SelectStmt") && node.SelectStmt.sortClause) for (const sortItem of node.SelectStmt.sortClause) {
|
|
120
|
+
if (!is$1(sortItem, "SortBy")) continue;
|
|
121
|
+
const sortDir = sortItem.SortBy.sortby_dir ?? "SORTBY_DEFAULT";
|
|
122
|
+
const sortNulls = sortItem.SortBy.sortby_nulls ?? "SORTBY_NULLS_DEFAULT";
|
|
123
|
+
if (sortDir === "SORTBY_DESC" && sortNulls === "SORTBY_NULLS_DEFAULT") {
|
|
124
|
+
if (sortItem.SortBy.node && is$1(sortItem.SortBy.node, "ColumnRef")) {
|
|
125
|
+
const sortColumnName = getLastColumnRefField(sortItem.SortBy.node);
|
|
126
|
+
if (!(sortColumnName !== null && whereHasIsNotNull(node.SelectStmt.whereClause, sortColumnName))) nudges.push({
|
|
127
|
+
kind: "NULLS_FIRST_IN_DESC_ORDER",
|
|
128
|
+
severity: "INFO",
|
|
129
|
+
message: "ORDER BY … DESC sorts NULLs first — add NULLS LAST to push them to the end",
|
|
130
|
+
location: sortItem.SortBy.node.ColumnRef.location
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
105
135
|
if (is$1(node, "A_Expr")) {
|
|
106
136
|
if (node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0 && is$1(node.A_Expr.name[0], "String") && (node.A_Expr.name[0].String.sval === "=" || node.A_Expr.name[0].String.sval === "!=" || node.A_Expr.name[0].String.sval === "<>")) {
|
|
107
137
|
const leftIsNull = isNullConstant(node.A_Expr.lexpr);
|
|
@@ -121,12 +151,20 @@ function parseNudges(node, stack) {
|
|
|
121
151
|
nudges.push({
|
|
122
152
|
kind: "AVOID_LEADING_WILDCARD_LIKE",
|
|
123
153
|
severity: "WARNING",
|
|
124
|
-
message: "
|
|
154
|
+
message: "Leading wildcard in LIKE/ILIKE prevents index usage — consider a GIN trigram index (pg_trgm) or full-text search",
|
|
125
155
|
location: stringNode?.location
|
|
126
156
|
});
|
|
127
157
|
}
|
|
128
158
|
}
|
|
129
159
|
}
|
|
160
|
+
if (is$1(node, "SelectStmt") && node.SelectStmt.sortClause) {
|
|
161
|
+
for (const sortItem of node.SelectStmt.sortClause) if (is$1(sortItem, "SortBy") && sortItem.SortBy.node && is$1(sortItem.SortBy.node, "FuncCall") && sortItem.SortBy.node.FuncCall.funcname?.some((name) => is$1(name, "String") && name.String.sval === "random")) nudges.push({
|
|
162
|
+
kind: "AVOID_ORDER_BY_RANDOM",
|
|
163
|
+
severity: "WARNING",
|
|
164
|
+
message: "Avoid using ORDER BY random()",
|
|
165
|
+
location: sortItem.SortBy.node.FuncCall.location
|
|
166
|
+
});
|
|
167
|
+
}
|
|
130
168
|
if (is$1(node, "SelectStmt") && node.SelectStmt.distinctClause) nudges.push({
|
|
131
169
|
kind: "AVOID_DISTINCT_WITHOUT_REASON",
|
|
132
170
|
severity: "WARNING",
|
|
@@ -168,6 +206,29 @@ function parseNudges(node, stack) {
|
|
|
168
206
|
});
|
|
169
207
|
}
|
|
170
208
|
}
|
|
209
|
+
if (is$1(node, "FuncCall")) {
|
|
210
|
+
const funcName = node.FuncCall.funcname;
|
|
211
|
+
if (funcName && funcName.length === 1 && is$1(funcName[0], "String") && funcName[0].String.sval === "count" && node.FuncCall.args && !node.FuncCall.agg_star && !node.FuncCall.agg_distinct) nudges.push({
|
|
212
|
+
kind: "PREFER_COUNT_STAR_OVER_COUNT_COLUMN",
|
|
213
|
+
severity: "INFO",
|
|
214
|
+
message: "Prefer COUNT(*) over COUNT(column) or COUNT(1) — COUNT(*) counts rows without checking for NULLs. If you need to count non-NULL values, COUNT(column) is correct.",
|
|
215
|
+
location: node.FuncCall.location
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (is$1(node, "SelectStmt") && node.SelectStmt.havingClause) {
|
|
219
|
+
if (!containsAggregate(node.SelectStmt.havingClause)) {
|
|
220
|
+
const having = node.SelectStmt.havingClause;
|
|
221
|
+
let location;
|
|
222
|
+
if (is$1(having, "A_Expr")) location = having.A_Expr.location;
|
|
223
|
+
else if (is$1(having, "BoolExpr")) location = having.BoolExpr.location;
|
|
224
|
+
nudges.push({
|
|
225
|
+
kind: "PREFER_WHERE_OVER_HAVING_FOR_NON_AGGREGATES",
|
|
226
|
+
severity: "INFO",
|
|
227
|
+
message: "Non-aggregate condition in HAVING should be in WHERE",
|
|
228
|
+
location
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
171
232
|
if (is$1(node, "A_Expr")) {
|
|
172
233
|
if (node.A_Expr.kind === "AEXPR_IN") {
|
|
173
234
|
let list;
|
|
@@ -181,6 +242,45 @@ function parseNudges(node, stack) {
|
|
|
181
242
|
});
|
|
182
243
|
}
|
|
183
244
|
}
|
|
245
|
+
if (is$1(node, "FuncCall")) {
|
|
246
|
+
const funcname = node.FuncCall.funcname?.[0] && is$1(node.FuncCall.funcname[0], "String") && node.FuncCall.funcname[0].String.sval;
|
|
247
|
+
if (funcname && [
|
|
248
|
+
"sum",
|
|
249
|
+
"count",
|
|
250
|
+
"avg",
|
|
251
|
+
"min",
|
|
252
|
+
"max"
|
|
253
|
+
].includes(funcname.toLowerCase())) {
|
|
254
|
+
const firstArg = node.FuncCall.args?.[0];
|
|
255
|
+
if (firstArg && isANode$1(firstArg) && is$1(firstArg, "CaseExpr")) {
|
|
256
|
+
const caseExpr = firstArg.CaseExpr;
|
|
257
|
+
if (caseExpr.args && caseExpr.args.length === 1) {
|
|
258
|
+
const defresult = caseExpr.defresult;
|
|
259
|
+
if (!defresult || isANode$1(defresult) && is$1(defresult, "A_Const") && (defresult.A_Const.isnull !== void 0 || defresult.A_Const.ival !== void 0 && (defresult.A_Const.ival.ival === 0 || defresult.A_Const.ival.ival === void 0))) nudges.push({
|
|
260
|
+
kind: "PREFER_FILTER_OVER_CASE_IN_AGGREGATE",
|
|
261
|
+
severity: "INFO",
|
|
262
|
+
message: "Use FILTER (WHERE ...) instead of CASE inside aggregate functions",
|
|
263
|
+
location: node.FuncCall.location
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (is$1(node, "SelectStmt") && node.SelectStmt.op === "SETOP_UNION" && !node.SelectStmt.all) nudges.push({
|
|
270
|
+
kind: "PREFER_UNION_ALL_OVER_UNION",
|
|
271
|
+
severity: "INFO",
|
|
272
|
+
message: "UNION removes duplicates with an implicit sort — use UNION ALL if deduplication is not needed"
|
|
273
|
+
});
|
|
274
|
+
if (is$1(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0) {
|
|
275
|
+
const opNode = node.A_Expr.name[0];
|
|
276
|
+
const op = is$1(opNode, "String") ? opNode.String.sval : null;
|
|
277
|
+
if (op && isExistenceCheckPattern(node.A_Expr.lexpr, node.A_Expr.rexpr, op)) nudges.push({
|
|
278
|
+
kind: "USE_EXISTS_NOT_COUNT_FOR_EXISTENCE_CHECK",
|
|
279
|
+
severity: "INFO",
|
|
280
|
+
message: "Use EXISTS instead of COUNT for existence checks",
|
|
281
|
+
location: node.A_Expr.location
|
|
282
|
+
});
|
|
283
|
+
}
|
|
184
284
|
return nudges;
|
|
185
285
|
}
|
|
186
286
|
function containsColumnRef(args) {
|
|
@@ -216,6 +316,48 @@ function getStringConstantValue(node) {
|
|
|
216
316
|
if (isANode$1(node) && is$1(node, "A_Const") && node.A_Const.sval) return node.A_Const.sval.sval || null;
|
|
217
317
|
return null;
|
|
218
318
|
}
|
|
319
|
+
function getLastColumnRefField(columnRef) {
|
|
320
|
+
const fields = columnRef.ColumnRef.fields;
|
|
321
|
+
if (!fields || fields.length === 0) return null;
|
|
322
|
+
const lastField = fields[fields.length - 1];
|
|
323
|
+
if (isANode$1(lastField) && is$1(lastField, "String")) return lastField.String.sval || null;
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
function whereHasIsNotNull(whereClause, columnName) {
|
|
327
|
+
if (!whereClause) return false;
|
|
328
|
+
let found = false;
|
|
329
|
+
Walker.shallowMatch(whereClause, "NullTest", (node) => {
|
|
330
|
+
if (node.NullTest.nulltesttype === "IS_NOT_NULL" && node.NullTest.arg && is$1(node.NullTest.arg, "ColumnRef")) {
|
|
331
|
+
if (getLastColumnRefField(node.NullTest.arg) === columnName) found = true;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return found;
|
|
335
|
+
}
|
|
336
|
+
const AGGREGATE_FUNCTIONS = new Set([
|
|
337
|
+
"count",
|
|
338
|
+
"sum",
|
|
339
|
+
"avg",
|
|
340
|
+
"min",
|
|
341
|
+
"max",
|
|
342
|
+
"array_agg",
|
|
343
|
+
"string_agg",
|
|
344
|
+
"bool_and",
|
|
345
|
+
"bool_or",
|
|
346
|
+
"every"
|
|
347
|
+
]);
|
|
348
|
+
function containsAggregate(node) {
|
|
349
|
+
if (!node || typeof node !== "object") return false;
|
|
350
|
+
if (Array.isArray(node)) return node.some(containsAggregate);
|
|
351
|
+
if (isANode$1(node) && is$1(node, "FuncCall")) {
|
|
352
|
+
const funcname = node.FuncCall.funcname;
|
|
353
|
+
if (funcname) {
|
|
354
|
+
for (const f of funcname) if (isANode$1(f) && is$1(f, "String") && AGGREGATE_FUNCTIONS.has(f.String.sval?.toLowerCase() ?? "")) return true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (isANode$1(node)) return containsAggregate(node[Object.keys(node)[0]]);
|
|
358
|
+
for (const child of Object.values(node)) if (containsAggregate(child)) return true;
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
219
361
|
function countBoolOrConditions(node) {
|
|
220
362
|
if (node.BoolExpr.boolop !== "OR_EXPR" || !node.BoolExpr.args) return 1;
|
|
221
363
|
let count = 0;
|
|
@@ -223,6 +365,54 @@ function countBoolOrConditions(node) {
|
|
|
223
365
|
else count += 1;
|
|
224
366
|
return count;
|
|
225
367
|
}
|
|
368
|
+
function isCountFuncCall(node) {
|
|
369
|
+
if (!node || typeof node !== "object") return false;
|
|
370
|
+
if (!isANode$1(node) || !is$1(node, "FuncCall")) return false;
|
|
371
|
+
const fc = node.FuncCall;
|
|
372
|
+
if (!(fc.funcname?.some((n) => is$1(n, "String") && n.String.sval === "count") ?? false)) return false;
|
|
373
|
+
if (fc.agg_star) return true;
|
|
374
|
+
if (fc.args && fc.args.length === 1 && isANode$1(fc.args[0]) && is$1(fc.args[0], "A_Const")) return true;
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
function isSubLinkWithCount(node) {
|
|
378
|
+
if (!node || typeof node !== "object") return false;
|
|
379
|
+
if (!isANode$1(node) || !is$1(node, "SubLink")) return false;
|
|
380
|
+
const subselect = node.SubLink.subselect;
|
|
381
|
+
if (!subselect || !isANode$1(subselect) || !is$1(subselect, "SelectStmt")) return false;
|
|
382
|
+
const targets = subselect.SelectStmt.targetList;
|
|
383
|
+
if (!targets || targets.length !== 1) return false;
|
|
384
|
+
const target = targets[0];
|
|
385
|
+
if (!isANode$1(target) || !is$1(target, "ResTarget") || !target.ResTarget.val) return false;
|
|
386
|
+
return isCountFuncCall(target.ResTarget.val);
|
|
387
|
+
}
|
|
388
|
+
function isCountExpression(node) {
|
|
389
|
+
return isCountFuncCall(node) || isSubLinkWithCount(node);
|
|
390
|
+
}
|
|
391
|
+
function getIntegerConstantValue(node) {
|
|
392
|
+
if (!node || typeof node !== "object") return null;
|
|
393
|
+
if (!isANode$1(node) || !is$1(node, "A_Const")) return null;
|
|
394
|
+
if (node.A_Const.ival === void 0) return null;
|
|
395
|
+
return node.A_Const.ival.ival ?? 0;
|
|
396
|
+
}
|
|
397
|
+
function isExistenceCheckPattern(lexpr, rexpr, op) {
|
|
398
|
+
if (isCountExpression(lexpr)) {
|
|
399
|
+
const val = getIntegerConstantValue(rexpr);
|
|
400
|
+
if (val !== null) {
|
|
401
|
+
if (op === ">" && val === 0) return true;
|
|
402
|
+
if (op === ">=" && val === 1) return true;
|
|
403
|
+
if ((op === "!=" || op === "<>") && val === 0) return true;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (isCountExpression(rexpr)) {
|
|
407
|
+
const val = getIntegerConstantValue(lexpr);
|
|
408
|
+
if (val !== null) {
|
|
409
|
+
if (op === "<" && val === 0) return true;
|
|
410
|
+
if (op === "<=" && val === 1) return true;
|
|
411
|
+
if ((op === "!=" || op === "<>") && val === 0) return true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
226
416
|
|
|
227
417
|
//#endregion
|
|
228
418
|
//#region \0@oxc-project+runtime@0.112.0/helpers/typeof.js
|
|
@@ -686,6 +876,11 @@ var Analyzer = class {
|
|
|
686
876
|
tags: [],
|
|
687
877
|
queryWithoutTags: trimmedQuery
|
|
688
878
|
};
|
|
879
|
+
const afterComment = trimmedQuery.slice(endPosition + 2).trim();
|
|
880
|
+
if (afterComment && afterComment !== ";") return {
|
|
881
|
+
tags: [],
|
|
882
|
+
queryWithoutTags: trimmedQuery
|
|
883
|
+
};
|
|
689
884
|
const queryWithoutTags = trimmedQuery.slice(0, startPosition);
|
|
690
885
|
const tagString = trimmedQuery.slice(startPosition + 2, endPosition).trim();
|
|
691
886
|
if (!tagString || typeof tagString !== "string") return {
|