@query-doctor/core 0.0.3 → 0.0.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 +222 -97
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +218 -97
- package/dist/index.js.map +1 -1
- package/dist/optimizer/genalgo.d.ts +9 -5
- package/dist/optimizer/genalgo.d.ts.map +1 -1
- package/dist/optimizer/genalgo.js +304 -0
- package/dist/optimizer/statistics.d.ts +1 -24
- package/dist/optimizer/statistics.d.ts.map +1 -1
- package/dist/optimizer/statistics.js +700 -0
- package/dist/package.json +25 -0
- package/dist/sql/analyzer.d.ts +3 -3
- package/dist/sql/analyzer.js +270 -0
- package/dist/sql/analyzer.test.d.ts +2 -0
- package/dist/sql/analyzer.test.d.ts.map +1 -0
- package/dist/sql/analyzer_test.js +584 -0
- package/dist/sql/builder.js +77 -0
- package/dist/sql/database.d.ts +5 -0
- package/dist/sql/database.d.ts.map +1 -1
- package/dist/sql/database.js +20 -0
- package/dist/sql/indexes.d.ts +8 -0
- package/dist/sql/indexes.d.ts.map +1 -0
- package/dist/sql/indexes.js +12 -0
- package/dist/sql/nudges.js +241 -0
- package/dist/sql/permutations.test.d.ts +2 -0
- package/dist/sql/permutations.test.d.ts.map +1 -0
- package/dist/sql/permutations_test.js +53 -0
- package/dist/sql/pg-identifier.d.ts +9 -0
- package/dist/sql/pg-identifier.d.ts.map +1 -0
- package/dist/sql/pg-identifier.test.d.ts +2 -0
- package/dist/sql/pg-identifier.test.d.ts.map +1 -0
- package/dist/sql/walker.d.ts +2 -2
- package/dist/sql/walker.js +295 -0
- package/package.json +2 -2
- package/dist/index.mjs +0 -24297
- package/dist/index.mjs.map +0 -1
- package/dist/sql/schema_dump.d.ts +0 -132
- package/dist/sql/schema_dump.d.ts.map +0 -1
- package/dist/sql/trace.d.ts +0 -1
- package/dist/sql/trace.d.ts.map +0 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const PostgresVersion = z.string().brand("PostgresVersion");
|
|
3
|
+
/**
|
|
4
|
+
* Drops a disabled index. Rollsback if it fails for any reason
|
|
5
|
+
* @returns Did dropping the index succeed?
|
|
6
|
+
*/
|
|
7
|
+
export async function dropIndex(tx, index) {
|
|
8
|
+
try {
|
|
9
|
+
await tx.exec(`
|
|
10
|
+
savepoint idx_drop;
|
|
11
|
+
drop index if exists ${index} cascade;
|
|
12
|
+
`);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// no problem if droping the index fails. It should throw an error
|
|
17
|
+
await tx.exec(`rollback to idx_drop`);
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IndexedTable } from "../optimizer/statistics.js";
|
|
2
|
+
export declare function isIndexSupported(index: IndexedTable): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Doesn't necessarily decide whether the index can be dropped but can be
|
|
5
|
+
* used to not even try dropping indexes that _definitely_ cannot be dropped
|
|
6
|
+
*/
|
|
7
|
+
export declare function isIndexProbablyDroppable(index: IndexedTable): boolean;
|
|
8
|
+
//# sourceMappingURL=indexes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"indexes.d.ts","sourceRoot":"","sources":["../../src/sql/indexes.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,YAAY,WAEnD;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,YAAY,WAI3D"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function isIndexSupported(index) {
|
|
2
|
+
return index.index_type === "btree";
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Doesn't necessarily decide whether the index can be dropped but can be
|
|
6
|
+
* used to not even try dropping indexes that _definitely_ cannot be dropped
|
|
7
|
+
*/
|
|
8
|
+
export function isIndexProbablyDroppable(index) {
|
|
9
|
+
/* TODO: until we have a better solution, this is the best we have */
|
|
10
|
+
/* The is_unique check is problematic only if the column is declared as unique */
|
|
11
|
+
return !index.is_primary && !index.is_unique;
|
|
12
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
function is(node, kind) {
|
|
2
|
+
return kind in node;
|
|
3
|
+
}
|
|
4
|
+
function isANode(node) {
|
|
5
|
+
if (typeof node !== "object" || node === null) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const keys = Object.keys(node);
|
|
9
|
+
return keys.length === 1 && /^[A-Z]/.test(keys[0]);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Detect nudges for a single node during AST traversal.
|
|
13
|
+
* Returns an array of nudges found for this node.
|
|
14
|
+
*/
|
|
15
|
+
export function parseNudges(node, stack) {
|
|
16
|
+
const nudges = [];
|
|
17
|
+
if (is(node, "A_Star")) {
|
|
18
|
+
nudges.push({
|
|
19
|
+
kind: "AVOID_SELECT_STAR",
|
|
20
|
+
severity: "INFO",
|
|
21
|
+
message: "Avoid using SELECT *",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// Detect functions on columns in WHERE clause
|
|
25
|
+
if (is(node, "FuncCall")) {
|
|
26
|
+
// Check if we're in a WHERE clause context
|
|
27
|
+
const inWhereClause = stack.some((item) => item === "whereClause");
|
|
28
|
+
if (inWhereClause && node.FuncCall.args) {
|
|
29
|
+
// Check if any function arguments contain column references
|
|
30
|
+
const hasColumnRef = containsColumnRef(node.FuncCall.args);
|
|
31
|
+
if (hasColumnRef) {
|
|
32
|
+
nudges.push({
|
|
33
|
+
kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
|
|
34
|
+
severity: "WARNING",
|
|
35
|
+
message: "Avoid using functions on columns in WHERE clause",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// Detect unbounded queries (missing WHERE or LIMIT on table queries)
|
|
41
|
+
if (is(node, "SelectStmt")) {
|
|
42
|
+
// Only check top-level SELECT statements (not subqueries)
|
|
43
|
+
const isSubquery = stack.some((item) => item === "RangeSubselect" ||
|
|
44
|
+
item === "SubLink" ||
|
|
45
|
+
item === "CommonTableExpr");
|
|
46
|
+
if (!isSubquery) {
|
|
47
|
+
const hasFromClause = node.SelectStmt.fromClause && node.SelectStmt.fromClause.length > 0;
|
|
48
|
+
if (hasFromClause) {
|
|
49
|
+
// Check if this SELECT queries actual tables (not just subqueries or CTEs)
|
|
50
|
+
const hasActualTables = node.SelectStmt.fromClause.some((fromItem) => {
|
|
51
|
+
return (is(fromItem, "RangeVar") ||
|
|
52
|
+
(is(fromItem, "JoinExpr") && hasActualTablesInJoin(fromItem)));
|
|
53
|
+
});
|
|
54
|
+
if (hasActualTables) {
|
|
55
|
+
// Check for missing WHERE clause
|
|
56
|
+
if (!node.SelectStmt.whereClause) {
|
|
57
|
+
nudges.push({
|
|
58
|
+
kind: "MISSING_WHERE_CLAUSE",
|
|
59
|
+
severity: "INFO",
|
|
60
|
+
message: "Missing WHERE clause",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Check for missing LIMIT clause
|
|
64
|
+
if (!node.SelectStmt.limitCount) {
|
|
65
|
+
nudges.push({
|
|
66
|
+
kind: "MISSING_LIMIT_CLAUSE",
|
|
67
|
+
severity: "INFO",
|
|
68
|
+
message: "Missing LIMIT clause",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Detect NULL comparison issues (= NULL instead of IS NULL)
|
|
76
|
+
if (is(node, "A_Expr")) {
|
|
77
|
+
const isEqualityOp = node.A_Expr.kind === "AEXPR_OP" &&
|
|
78
|
+
node.A_Expr.name &&
|
|
79
|
+
node.A_Expr.name.length > 0 &&
|
|
80
|
+
is(node.A_Expr.name[0], "String") &&
|
|
81
|
+
(node.A_Expr.name[0].String.sval === "=" ||
|
|
82
|
+
node.A_Expr.name[0].String.sval === "!=" ||
|
|
83
|
+
node.A_Expr.name[0].String.sval === "<>");
|
|
84
|
+
if (isEqualityOp) {
|
|
85
|
+
const leftIsNull = isNullConstant(node.A_Expr.lexpr);
|
|
86
|
+
const rightIsNull = isNullConstant(node.A_Expr.rexpr);
|
|
87
|
+
if (leftIsNull || rightIsNull) {
|
|
88
|
+
nudges.push({
|
|
89
|
+
kind: "USE_IS_NULL_NOT_EQUALS",
|
|
90
|
+
severity: "WARNING",
|
|
91
|
+
message: "Use IS NULL instead of = or != or <> for NULL comparisons",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Detect LIKE with leading wildcards
|
|
96
|
+
const isLikeOp = node.A_Expr.kind === "AEXPR_LIKE" || node.A_Expr.kind === "AEXPR_ILIKE";
|
|
97
|
+
if (isLikeOp && node.A_Expr.rexpr) {
|
|
98
|
+
const patternString = getStringConstantValue(node.A_Expr.rexpr);
|
|
99
|
+
if (patternString && patternString.startsWith("%")) {
|
|
100
|
+
nudges.push({
|
|
101
|
+
kind: "AVOID_LEADING_WILDCARD_LIKE",
|
|
102
|
+
severity: "WARNING",
|
|
103
|
+
message: "Avoid using LIKE with leading wildcards",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Detect DISTINCT usage
|
|
109
|
+
if (is(node, "SelectStmt") && node.SelectStmt.distinctClause) {
|
|
110
|
+
nudges.push({
|
|
111
|
+
kind: "AVOID_DISTINCT_WITHOUT_REASON",
|
|
112
|
+
severity: "WARNING",
|
|
113
|
+
message: "Avoid using DISTINCT without a reason",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
// Detect cartesian joins (missing JOIN conditions)
|
|
117
|
+
if (is(node, "JoinExpr")) {
|
|
118
|
+
// Check if JOIN has no qualification (ON clause)
|
|
119
|
+
if (!node.JoinExpr.quals) {
|
|
120
|
+
nudges.push({
|
|
121
|
+
kind: "MISSING_JOIN_CONDITION",
|
|
122
|
+
severity: "WARNING",
|
|
123
|
+
message: "Missing JOIN condition",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Detect multiple tables in FROM without explicit JOINs (old-style cartesian joins)
|
|
128
|
+
if (is(node, "SelectStmt") &&
|
|
129
|
+
node.SelectStmt.fromClause &&
|
|
130
|
+
node.SelectStmt.fromClause.length > 1) {
|
|
131
|
+
// Check if there are multiple RangeVar (tables) directly in FROM clause
|
|
132
|
+
const tableCount = node.SelectStmt.fromClause.filter((item) => is(item, "RangeVar")).length;
|
|
133
|
+
if (tableCount > 1) {
|
|
134
|
+
nudges.push({
|
|
135
|
+
kind: "MISSING_JOIN_CONDITION",
|
|
136
|
+
severity: "WARNING",
|
|
137
|
+
message: "Missing JOIN condition",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// Detect too many OR conditions
|
|
142
|
+
if (is(node, "BoolExpr") && node.BoolExpr.boolop === "OR_EXPR") {
|
|
143
|
+
const orCount = countBoolOrConditions(node);
|
|
144
|
+
if (orCount >= 3) {
|
|
145
|
+
nudges.push({
|
|
146
|
+
kind: "CONSIDER_IN_INSTEAD_OF_MANY_ORS",
|
|
147
|
+
severity: "WARNING",
|
|
148
|
+
message: "Consider using IN instead of many ORs",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return nudges;
|
|
153
|
+
}
|
|
154
|
+
function containsColumnRef(args) {
|
|
155
|
+
// Recursively check if any argument contains a ColumnRef
|
|
156
|
+
for (const arg of args) {
|
|
157
|
+
if (hasColumnRefInNode(arg)) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
function hasColumnRefInNode(node) {
|
|
164
|
+
if (isANode(node) && is(node, "ColumnRef")) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (typeof node !== "object" || node === null) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(node)) {
|
|
171
|
+
return node.some((item) => hasColumnRefInNode(item));
|
|
172
|
+
}
|
|
173
|
+
if (isANode(node)) {
|
|
174
|
+
const keys = Object.keys(node);
|
|
175
|
+
// @ts-expect-error | nodes don't allow dynamic access but it's the only way to do it
|
|
176
|
+
return hasColumnRefInNode(node[keys[0]]);
|
|
177
|
+
}
|
|
178
|
+
for (const child of Object.values(node)) {
|
|
179
|
+
if (hasColumnRefInNode(child)) {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
function hasActualTablesInJoin(joinExpr) {
|
|
186
|
+
// Check left side of join
|
|
187
|
+
if (joinExpr.JoinExpr.larg && is(joinExpr.JoinExpr.larg, "RangeVar")) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
if (joinExpr.JoinExpr.larg && is(joinExpr.JoinExpr.larg, "JoinExpr")) {
|
|
191
|
+
if (hasActualTablesInJoin(joinExpr.JoinExpr.larg)) {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Check right side of join
|
|
196
|
+
if (joinExpr.JoinExpr.rarg && is(joinExpr.JoinExpr.rarg, "RangeVar")) {
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
if (joinExpr.JoinExpr.rarg && is(joinExpr.JoinExpr.rarg, "JoinExpr")) {
|
|
200
|
+
if (hasActualTablesInJoin(joinExpr.JoinExpr.rarg)) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
function isNullConstant(node) {
|
|
207
|
+
if (!node || typeof node !== "object") {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
if (isANode(node) && is(node, "A_Const")) {
|
|
211
|
+
// Check if it's a NULL constant
|
|
212
|
+
return node.A_Const.isnull !== undefined;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
function getStringConstantValue(node) {
|
|
217
|
+
if (!node || typeof node !== "object") {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (isANode(node) && is(node, "A_Const") && node.A_Const.sval) {
|
|
221
|
+
return node.A_Const.sval.sval || null;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
function countBoolOrConditions(node) {
|
|
226
|
+
if (node.BoolExpr.boolop !== "OR_EXPR" || !node.BoolExpr.args) {
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
let count = 0;
|
|
230
|
+
for (const arg of node.BoolExpr.args) {
|
|
231
|
+
if (isANode(arg) &&
|
|
232
|
+
is(arg, "BoolExpr") &&
|
|
233
|
+
arg.BoolExpr.boolop === "OR_EXPR") {
|
|
234
|
+
count += countBoolOrConditions(arg);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
count += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
241
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permutations.test.d.ts","sourceRoot":"","sources":["../../src/sql/permutations.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { permuteWithFeedback, PROCEED, SKIP } from "../optimizer/genalgo.js";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
test("permutations", () => {
|
|
5
|
+
const fn = permuteWithFeedback([1, 2, 3]);
|
|
6
|
+
const next1 = fn.next(PROCEED);
|
|
7
|
+
const next2 = fn.next(PROCEED);
|
|
8
|
+
const next3 = fn.next(PROCEED);
|
|
9
|
+
const next4 = fn.next(PROCEED);
|
|
10
|
+
const next5 = fn.next(PROCEED);
|
|
11
|
+
const next6 = fn.next(PROCEED);
|
|
12
|
+
const next7 = fn.next(PROCEED);
|
|
13
|
+
const next8 = fn.next(PROCEED);
|
|
14
|
+
const next9 = fn.next(PROCEED);
|
|
15
|
+
const next10 = fn.next(PROCEED);
|
|
16
|
+
const next11 = fn.next(PROCEED);
|
|
17
|
+
const next12 = fn.next(PROCEED);
|
|
18
|
+
const next13 = fn.next(PROCEED);
|
|
19
|
+
const next14 = fn.next(PROCEED);
|
|
20
|
+
const next15 = fn.next(PROCEED);
|
|
21
|
+
const next16 = fn.next(PROCEED);
|
|
22
|
+
assert.deepStrictEqual(next1.value, [1]);
|
|
23
|
+
assert.deepStrictEqual(next2.value, [1, 2]);
|
|
24
|
+
assert.deepStrictEqual(next3.value, [1, 2, 3]);
|
|
25
|
+
assert.deepStrictEqual(next4.value, [1, 3]);
|
|
26
|
+
assert.deepStrictEqual(next5.value, [1, 3, 2]);
|
|
27
|
+
assert.deepStrictEqual(next6.value, [2]);
|
|
28
|
+
assert.deepStrictEqual(next7.value, [2, 1]);
|
|
29
|
+
assert.deepStrictEqual(next8.value, [2, 1, 3]);
|
|
30
|
+
assert.deepStrictEqual(next9.value, [2, 3]);
|
|
31
|
+
assert.deepStrictEqual(next10.value, [2, 3, 1]);
|
|
32
|
+
assert.deepStrictEqual(next11.value, [3]);
|
|
33
|
+
assert.deepStrictEqual(next12.value, [3, 1]);
|
|
34
|
+
assert.deepStrictEqual(next13.value, [3, 1, 2]);
|
|
35
|
+
assert.deepStrictEqual(next14.value, [3, 2]);
|
|
36
|
+
assert.deepStrictEqual(next15.value, [3, 2, 1]);
|
|
37
|
+
assert.deepStrictEqual(next16.done, true);
|
|
38
|
+
});
|
|
39
|
+
test("permutations with skip", () => {
|
|
40
|
+
const fn = permuteWithFeedback([1, 2, 3]);
|
|
41
|
+
const next1 = fn.next(PROCEED);
|
|
42
|
+
const next2 = fn.next(SKIP);
|
|
43
|
+
const next3 = fn.next(PROCEED);
|
|
44
|
+
const next4 = fn.next(SKIP);
|
|
45
|
+
const next5 = fn.next(SKIP);
|
|
46
|
+
const next6 = fn.next(SKIP);
|
|
47
|
+
assert.deepStrictEqual(next1.value, [1]);
|
|
48
|
+
assert.deepStrictEqual(next2.value, [2]);
|
|
49
|
+
assert.deepStrictEqual(next3.value, [2, 1]);
|
|
50
|
+
assert.deepStrictEqual(next4.value, [2, 3]);
|
|
51
|
+
assert.deepStrictEqual(next5.value, [3]);
|
|
52
|
+
assert.deepStrictEqual(next6.done, true);
|
|
53
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class PgIdentifier {
|
|
2
|
+
private readonly value;
|
|
3
|
+
private readonly quoted;
|
|
4
|
+
private constructor();
|
|
5
|
+
static fromString(identifier: string): PgIdentifier;
|
|
6
|
+
toString(): string;
|
|
7
|
+
private static readonly reservedKeywords;
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=pg-identifier.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pg-identifier.d.ts","sourceRoot":"","sources":["../../src/sql/pg-identifier.ts"],"names":[],"mappings":"AAAA,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAFzB,OAAO;IAKP,MAAM,CAAC,UAAU,CAAC,UAAU,EAAE,MAAM,GAAG,YAAY;IAYnD,QAAQ,IAAI,MAAM;IAOlB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CA4GrC;CACJ"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pg-identifier.test.d.ts","sourceRoot":"","sources":["../../src/sql/pg-identifier.test.ts"],"names":[],"mappings":""}
|
package/dist/sql/walker.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Node } from "@pgsql/types";
|
|
2
|
-
import type { DiscoveredColumnReference } from "./analyzer.
|
|
3
|
-
import type { Nudge } from "./nudges.
|
|
2
|
+
import type { DiscoveredColumnReference } from "./analyzer.js";
|
|
3
|
+
import type { Nudge } from "./nudges.js";
|
|
4
4
|
/** Information about tables that appear in the query */
|
|
5
5
|
export type TableMappings = Map<string, ColumnReferencePart>;
|
|
6
6
|
/**
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { deparseSync } from "pgsql-deparser";
|
|
2
|
+
import { parseNudges } from "./nudges.js";
|
|
3
|
+
/**
|
|
4
|
+
* Walks the AST of a sql query and extracts query metadata.
|
|
5
|
+
* This pattern is used to segregate the mutable state that's more common for the
|
|
6
|
+
* AST walking process from the rest of the analyzer.
|
|
7
|
+
*/
|
|
8
|
+
export class Walker {
|
|
9
|
+
query;
|
|
10
|
+
tableMappings = new Map();
|
|
11
|
+
tempTables = new Set();
|
|
12
|
+
highlights = [];
|
|
13
|
+
indexRepresentations = new Set();
|
|
14
|
+
indexesToCheck = [];
|
|
15
|
+
highlightPositions = new Set();
|
|
16
|
+
// used for tallying the amount of times we see stuff so
|
|
17
|
+
// we have a better idea of what to start off the algorithm with
|
|
18
|
+
seenReferences = new Map();
|
|
19
|
+
shadowedAliases = [];
|
|
20
|
+
nudges = [];
|
|
21
|
+
constructor(query) {
|
|
22
|
+
this.query = query;
|
|
23
|
+
}
|
|
24
|
+
walk(root) {
|
|
25
|
+
// reset state in case the class instance is reused
|
|
26
|
+
// reassigning vars here instead of using `map.clear()` to prevent
|
|
27
|
+
// accidentally mutating existing references
|
|
28
|
+
this.tableMappings = new Map();
|
|
29
|
+
this.tempTables = new Set();
|
|
30
|
+
this.highlights = [];
|
|
31
|
+
this.indexRepresentations = new Set();
|
|
32
|
+
this.indexesToCheck = [];
|
|
33
|
+
this.highlightPositions = new Set();
|
|
34
|
+
this.seenReferences = new Map();
|
|
35
|
+
this.shadowedAliases = [];
|
|
36
|
+
this.nudges = [];
|
|
37
|
+
Walker.traverse(root, [], (node, stack) => {
|
|
38
|
+
const nodeNudges = parseNudges(node, stack);
|
|
39
|
+
this.nudges = [...this.nudges, ...nodeNudges];
|
|
40
|
+
// comments are not parsed here as they seem to be ignored.
|
|
41
|
+
//
|
|
42
|
+
// results cannot be indexed in any way because they alias a CTE
|
|
43
|
+
// with alias as (select ...)
|
|
44
|
+
// ^^^^^
|
|
45
|
+
if (is(node, "CommonTableExpr")) {
|
|
46
|
+
if (node.CommonTableExpr.ctename) {
|
|
47
|
+
this.tempTables.add(node.CommonTableExpr.ctename);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// results cannot be indexed in any way because they alias a subquery
|
|
51
|
+
// select ... from (...) as alias
|
|
52
|
+
// ^^^^^
|
|
53
|
+
if (is(node, "RangeSubselect")) {
|
|
54
|
+
if (node.RangeSubselect.alias?.aliasname) {
|
|
55
|
+
this.tempTables.add(node.RangeSubselect.alias.aliasname);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// select ... from (...) where col is null
|
|
59
|
+
// ^^^^^^^
|
|
60
|
+
if (is(node, "NullTest")) {
|
|
61
|
+
if (node.NullTest.arg &&
|
|
62
|
+
node.NullTest.nulltesttype &&
|
|
63
|
+
is(node.NullTest.arg, "ColumnRef")) {
|
|
64
|
+
this.add(node.NullTest.arg, {
|
|
65
|
+
where: { nulltest: node.NullTest.nulltesttype },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// can be indexed as the alias refers to a regular table
|
|
70
|
+
// but the alias has to be mapped to the original table name
|
|
71
|
+
// select ... from table as alias
|
|
72
|
+
// ^^^^^
|
|
73
|
+
if (is(node, "RangeVar") && node.RangeVar.relname) {
|
|
74
|
+
this.tableMappings.set(node.RangeVar.relname, {
|
|
75
|
+
text: node.RangeVar.relname,
|
|
76
|
+
start: node.RangeVar.location,
|
|
77
|
+
quoted: false,
|
|
78
|
+
});
|
|
79
|
+
// In theory we can't blindly map aliases to table names
|
|
80
|
+
// it's possible that two aliases point to different tables
|
|
81
|
+
// which postgres allows but is tricky to determine by just walking
|
|
82
|
+
// the AST like we're doing currently.
|
|
83
|
+
if (node.RangeVar.alias?.aliasname) {
|
|
84
|
+
const aliasName = node.RangeVar.alias.aliasname;
|
|
85
|
+
const existingMapping = this.tableMappings.get(aliasName);
|
|
86
|
+
const part = {
|
|
87
|
+
text: node.RangeVar.relname,
|
|
88
|
+
start: node.RangeVar.location,
|
|
89
|
+
// what goes here? the text here doesn't _really_ exist.
|
|
90
|
+
// so it can't be quoted or not quoted.
|
|
91
|
+
// Does it even matter?
|
|
92
|
+
quoted: true,
|
|
93
|
+
alias: aliasName,
|
|
94
|
+
};
|
|
95
|
+
// Postgres supports shadowing table aliases created in different levels of queries
|
|
96
|
+
// for example:
|
|
97
|
+
// ```
|
|
98
|
+
// SELECT t.id, t.name
|
|
99
|
+
// FROM users t
|
|
100
|
+
// WHERE EXISTS (
|
|
101
|
+
// SELECT 1
|
|
102
|
+
// FROM orders t -- shadows outer alias "t"
|
|
103
|
+
// WHERE t.user_id = users.id
|
|
104
|
+
// );
|
|
105
|
+
// ```
|
|
106
|
+
// but we're very unlikely to see this in practice. Every ORM I've seen so far
|
|
107
|
+
// has produced globally unique aliases. This is not worth the complexity currently.
|
|
108
|
+
// but is almost certainly guaranteed to be a problem in the future.
|
|
109
|
+
if (existingMapping) {
|
|
110
|
+
console.warn(`Ignoring alias ${aliasName} as it shadows an existing mapping. We currently do not support alias shadowing.`);
|
|
111
|
+
// Let the user know what happened but don't stop the show.
|
|
112
|
+
this.shadowedAliases.push(part);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.tableMappings.set(aliasName, part);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// select ... from table order by col asc
|
|
119
|
+
// ^^^^^^^^^^^^^^^^
|
|
120
|
+
if (is(node, "SortBy")) {
|
|
121
|
+
// we don't care about sorting by anything that's not a column reference
|
|
122
|
+
// because it couldn't be indexed anyway.
|
|
123
|
+
// TODO: mark that expression as unindexable? It's just better for debugging
|
|
124
|
+
if (node.SortBy.node && is(node.SortBy.node, "ColumnRef")) {
|
|
125
|
+
this.add(node.SortBy.node, {
|
|
126
|
+
sort: {
|
|
127
|
+
dir: node.SortBy.sortby_dir ?? "SORTBY_DEFAULT",
|
|
128
|
+
nulls: node.SortBy.sortby_nulls ?? "SORTBY_NULLS_DEFAULT",
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// select ... from table1 join table2 t2 on table1.col = t2.col
|
|
134
|
+
// ^^
|
|
135
|
+
if (is(node, "JoinExpr") && node.JoinExpr.quals) {
|
|
136
|
+
if (is(node.JoinExpr.quals, "A_Expr")) {
|
|
137
|
+
if (node.JoinExpr.quals.A_Expr.lexpr &&
|
|
138
|
+
is(node.JoinExpr.quals.A_Expr.lexpr, "ColumnRef")) {
|
|
139
|
+
this.add(node.JoinExpr.quals.A_Expr.lexpr);
|
|
140
|
+
}
|
|
141
|
+
if (node.JoinExpr.quals.A_Expr.rexpr &&
|
|
142
|
+
is(node.JoinExpr.quals.A_Expr.rexpr, "ColumnRef")) {
|
|
143
|
+
this.add(node.JoinExpr.quals.A_Expr.rexpr);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// any column reference anywhere
|
|
148
|
+
if (is(node, "ColumnRef")) {
|
|
149
|
+
// TODO: this approach needs refinement
|
|
150
|
+
for (let i = 0; i < stack.length; i++) {
|
|
151
|
+
const inReturningList = stack[i] === "returningList" &&
|
|
152
|
+
stack[i + 1] === "ResTarget" &&
|
|
153
|
+
stack[i + 2] === "val" &&
|
|
154
|
+
stack[i + 3] === "ColumnRef";
|
|
155
|
+
if (inReturningList) {
|
|
156
|
+
this.add(node, { ignored: true });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
// stack[i] === "SelectStmt" &&
|
|
161
|
+
stack[i + 1] === "targetList" &&
|
|
162
|
+
stack[i + 2] === "ResTarget" &&
|
|
163
|
+
stack[i + 3] === "val" &&
|
|
164
|
+
stack[i + 4] === "ColumnRef") {
|
|
165
|
+
// we don't want to index the columns that are being selected
|
|
166
|
+
this.add(node, { ignored: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// TODO: add functional index support here
|
|
170
|
+
if (stack[i] === "FuncCall" && stack[i + 1] === "args") {
|
|
171
|
+
// args of a function call can't be indexed (without functional indexes)
|
|
172
|
+
this.add(node, { ignored: true });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
this.add(node);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
highlights: this.highlights,
|
|
181
|
+
indexRepresentations: this.indexRepresentations,
|
|
182
|
+
indexesToCheck: this.indexesToCheck,
|
|
183
|
+
shadowedAliases: this.shadowedAliases,
|
|
184
|
+
tempTables: this.tempTables,
|
|
185
|
+
tableMappings: this.tableMappings,
|
|
186
|
+
nudges: this.nudges,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
add(node, options) {
|
|
190
|
+
if (!node.ColumnRef.location) {
|
|
191
|
+
console.error(`Node did not have a location. Skipping`, node);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (!node.ColumnRef.fields) {
|
|
195
|
+
console.error(node);
|
|
196
|
+
throw new Error("Column reference must have fields");
|
|
197
|
+
}
|
|
198
|
+
let ignored = options?.ignored ?? false;
|
|
199
|
+
let runningLength = node.ColumnRef.location;
|
|
200
|
+
const parts = node.ColumnRef.fields.map((field, i, length) => {
|
|
201
|
+
if (!is(field, "String") || !field.String.sval) {
|
|
202
|
+
const out = deparseSync(field);
|
|
203
|
+
ignored = true;
|
|
204
|
+
return {
|
|
205
|
+
quoted: out.startsWith('"'),
|
|
206
|
+
text: out,
|
|
207
|
+
start: runningLength,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
const start = runningLength;
|
|
211
|
+
const size = field.String.sval?.length ?? 0;
|
|
212
|
+
let quoted = false;
|
|
213
|
+
if (node.ColumnRef.location !== undefined) {
|
|
214
|
+
const boundary = this.query[runningLength];
|
|
215
|
+
if (boundary === '"') {
|
|
216
|
+
quoted = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// +1 for the dot that comes after
|
|
220
|
+
const isLastIteration = i === length.length - 1;
|
|
221
|
+
runningLength += size + (isLastIteration ? 0 : 1) + (quoted ? 2 : 0);
|
|
222
|
+
return {
|
|
223
|
+
text: field.String.sval,
|
|
224
|
+
start,
|
|
225
|
+
quoted,
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const end = runningLength;
|
|
229
|
+
if (this.highlightPositions.has(node.ColumnRef.location)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
this.highlightPositions.add(node.ColumnRef.location);
|
|
233
|
+
const highlighted = `${this.query.slice(node.ColumnRef.location, end)}`;
|
|
234
|
+
const seen = this.seenReferences.get(highlighted);
|
|
235
|
+
if (!ignored) {
|
|
236
|
+
this.seenReferences.set(highlighted, (seen ?? 0) + 1);
|
|
237
|
+
}
|
|
238
|
+
const ref = {
|
|
239
|
+
frequency: seen ?? 1,
|
|
240
|
+
representation: highlighted,
|
|
241
|
+
parts,
|
|
242
|
+
ignored: ignored ?? false,
|
|
243
|
+
position: {
|
|
244
|
+
start: node.ColumnRef.location,
|
|
245
|
+
end,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
if (options?.sort) {
|
|
249
|
+
ref.sort = options.sort;
|
|
250
|
+
}
|
|
251
|
+
if (options?.where) {
|
|
252
|
+
ref.where = options.where;
|
|
253
|
+
}
|
|
254
|
+
this.highlights.push(ref);
|
|
255
|
+
}
|
|
256
|
+
static traverse(node, stack, callback) {
|
|
257
|
+
if (isANode(node)) {
|
|
258
|
+
callback(node, [...stack, getNodeKind(node)]);
|
|
259
|
+
}
|
|
260
|
+
if (typeof node !== "object" || node === null) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (Array.isArray(node)) {
|
|
264
|
+
for (const item of node) {
|
|
265
|
+
if (isANode(item)) {
|
|
266
|
+
Walker.traverse(item, stack, callback);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (isANode(node)) {
|
|
271
|
+
const keys = Object.keys(node);
|
|
272
|
+
// @ts-expect-error | nodes don't allow dynamic access but it's the only way to do it
|
|
273
|
+
Walker.traverse(node[keys[0]], [...stack, getNodeKind(node)], callback);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
for (const [key, child] of Object.entries(node)) {
|
|
277
|
+
Walker.traverse(child, [...stack, key], callback);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function is(node, kind) {
|
|
283
|
+
return kind in node;
|
|
284
|
+
}
|
|
285
|
+
function getNodeKind(node) {
|
|
286
|
+
const keys = Object.keys(node);
|
|
287
|
+
return keys[0];
|
|
288
|
+
}
|
|
289
|
+
function isANode(node) {
|
|
290
|
+
if (typeof node !== "object" || node === null) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
const keys = Object.keys(node);
|
|
294
|
+
return keys.length === 1 && /^[A-Z]/.test(keys[0]);
|
|
295
|
+
}
|