@query-doctor/core 0.1.0 → 0.1.2
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 +40 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/optimizer/pss-rewriter.d.ts +11 -0
- package/dist/optimizer/pss-rewriter.d.ts.map +1 -0
- package/dist/optimizer/pss-rewriter.test.d.ts +2 -0
- package/dist/optimizer/pss-rewriter.test.d.ts.map +1 -0
- package/dist/sql/analyzer.d.ts +5 -1
- package/dist/sql/analyzer.d.ts.map +1 -1
- package/dist/sql/walker.d.ts +1 -0
- package/dist/sql/walker.d.ts.map +1 -1
- package/package.json +1 -1
- package/dist/optimizer/genalgo.js +0 -304
- package/dist/optimizer/statistics.js +0 -700
- package/dist/package.json +0 -25
- package/dist/sql/analyzer.js +0 -270
- package/dist/sql/analyzer_test.d.ts +0 -2
- package/dist/sql/analyzer_test.d.ts.map +0 -1
- package/dist/sql/analyzer_test.js +0 -584
- package/dist/sql/builder.js +0 -77
- package/dist/sql/database.js +0 -20
- package/dist/sql/indexes.js +0 -12
- package/dist/sql/nudges.js +0 -241
- package/dist/sql/permutations_test.d.ts +0 -2
- package/dist/sql/permutations_test.d.ts.map +0 -1
- package/dist/sql/permutations_test.js +0 -53
- package/dist/sql/walker.js +0 -295
|
@@ -1,584 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import fs from "node:fs";
|
|
3
|
-
import test from "node:test";
|
|
4
|
-
import dedent from "dedent";
|
|
5
|
-
import { Analyzer } from "./analyzer.js";
|
|
6
|
-
import { parse } from "@pgsql/parser";
|
|
7
|
-
test("analyzer test", async function () {
|
|
8
|
-
const analyzer = new Analyzer(parse);
|
|
9
|
-
const query = dedent `
|
|
10
|
-
select
|
|
11
|
-
"public"."team_user"."team_user_id",
|
|
12
|
-
"public"."team_user"."team_id"
|
|
13
|
-
from
|
|
14
|
-
"public"."team_user"
|
|
15
|
-
where
|
|
16
|
-
(
|
|
17
|
-
1 = 1
|
|
18
|
-
and "public"."team_user"."team_id" in ($1)
|
|
19
|
-
)
|
|
20
|
-
offset
|
|
21
|
-
$2`;
|
|
22
|
-
const { indexesToCheck } = await analyzer.analyze(query);
|
|
23
|
-
assert.deepStrictEqual(indexesToCheck, [
|
|
24
|
-
{
|
|
25
|
-
frequency: 1,
|
|
26
|
-
representation: '"public"."team_user"."team_id"',
|
|
27
|
-
parts: [
|
|
28
|
-
{ quoted: true, start: 135, text: "public" },
|
|
29
|
-
{ quoted: true, start: 144, text: "team_user" },
|
|
30
|
-
{ quoted: true, start: 156, text: "team_id" },
|
|
31
|
-
],
|
|
32
|
-
ignored: false,
|
|
33
|
-
position: { start: 135, end: 165 },
|
|
34
|
-
},
|
|
35
|
-
]);
|
|
36
|
-
});
|
|
37
|
-
test("analyzer test with ordering", async function () {
|
|
38
|
-
const analyzer = new Analyzer(parse);
|
|
39
|
-
const query = dedent `
|
|
40
|
-
select
|
|
41
|
-
"public"."team"."team_id"
|
|
42
|
-
from
|
|
43
|
-
"public"."team"
|
|
44
|
-
order by
|
|
45
|
-
team.team_id desc nulls first
|
|
46
|
-
`;
|
|
47
|
-
const { indexesToCheck } = await analyzer.analyze(query);
|
|
48
|
-
assert.deepStrictEqual(indexesToCheck, [
|
|
49
|
-
{
|
|
50
|
-
frequency: 1,
|
|
51
|
-
representation: "team.team_id",
|
|
52
|
-
parts: [
|
|
53
|
-
{ quoted: false, start: 42, text: "team" },
|
|
54
|
-
{ quoted: false, start: 74, text: "team_id" },
|
|
55
|
-
],
|
|
56
|
-
ignored: false,
|
|
57
|
-
position: { start: 69, end: 81 },
|
|
58
|
-
sort: {
|
|
59
|
-
dir: "SORTBY_DESC",
|
|
60
|
-
nulls: "SORTBY_NULLS_FIRST",
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
]);
|
|
64
|
-
});
|
|
65
|
-
test("analyzer isnull", async function () {
|
|
66
|
-
const analyzer = new Analyzer(parse);
|
|
67
|
-
const query = dedent `
|
|
68
|
-
select * from team
|
|
69
|
-
where team.deleted_at is null
|
|
70
|
-
`;
|
|
71
|
-
const { indexesToCheck } = await analyzer.analyze(query);
|
|
72
|
-
assert.deepStrictEqual(indexesToCheck, [
|
|
73
|
-
{
|
|
74
|
-
frequency: 1,
|
|
75
|
-
representation: "team.deleted_at",
|
|
76
|
-
parts: [
|
|
77
|
-
{ text: "team", start: 14, quoted: false },
|
|
78
|
-
{ text: "deleted_at", start: 30, quoted: false },
|
|
79
|
-
],
|
|
80
|
-
ignored: false,
|
|
81
|
-
position: { start: 25, end: 40 },
|
|
82
|
-
where: { nulltest: "IS_NULL" },
|
|
83
|
-
},
|
|
84
|
-
]);
|
|
85
|
-
});
|
|
86
|
-
test("analyzer test", async function () {
|
|
87
|
-
const analyzer = new Analyzer(parse);
|
|
88
|
-
const query = dedent `
|
|
89
|
-
select
|
|
90
|
-
COUNT(*) as "_count._all"
|
|
91
|
-
from
|
|
92
|
-
(
|
|
93
|
-
select
|
|
94
|
-
"public"."team"."team_id"
|
|
95
|
-
from
|
|
96
|
-
"public"."team"
|
|
97
|
-
where
|
|
98
|
-
(
|
|
99
|
-
"public"."team"."deleted_at" is null
|
|
100
|
-
and exists (
|
|
101
|
-
select
|
|
102
|
-
"t0"."team_id"
|
|
103
|
-
from
|
|
104
|
-
"public"."team_user" as "t0"
|
|
105
|
-
where
|
|
106
|
-
(
|
|
107
|
-
"t0"."user_id" = $1
|
|
108
|
-
and ("public"."team"."team_id") = ("t0"."team_id")
|
|
109
|
-
and "t0"."team_id" is not null
|
|
110
|
-
)
|
|
111
|
-
)
|
|
112
|
-
)
|
|
113
|
-
offset
|
|
114
|
-
$2
|
|
115
|
-
) as "sub"`;
|
|
116
|
-
const { indexesToCheck } = await analyzer.analyze(query);
|
|
117
|
-
assert.deepStrictEqual(indexesToCheck, [
|
|
118
|
-
{
|
|
119
|
-
frequency: 1,
|
|
120
|
-
representation: '"t0"."team_id"',
|
|
121
|
-
parts: [
|
|
122
|
-
{ text: "team_user", start: 273, quoted: true, alias: "t0" },
|
|
123
|
-
{ text: "team_id", start: 454, quoted: true },
|
|
124
|
-
],
|
|
125
|
-
ignored: false,
|
|
126
|
-
position: { start: 449, end: 463 },
|
|
127
|
-
where: {
|
|
128
|
-
nulltest: "IS_NOT_NULL",
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
frequency: 1,
|
|
133
|
-
representation: '"public"."team"."team_id"',
|
|
134
|
-
parts: [
|
|
135
|
-
{ text: "public", start: 385, quoted: true },
|
|
136
|
-
{ text: "team", start: 394, quoted: true },
|
|
137
|
-
{ text: "team_id", start: 401, quoted: true },
|
|
138
|
-
],
|
|
139
|
-
ignored: false,
|
|
140
|
-
position: { start: 385, end: 410 },
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
frequency: 1,
|
|
144
|
-
representation: '"t0"."user_id"',
|
|
145
|
-
parts: [
|
|
146
|
-
{ text: "team_user", start: 273, quoted: true, alias: "t0" },
|
|
147
|
-
{ text: "user_id", start: 351, quoted: true },
|
|
148
|
-
],
|
|
149
|
-
ignored: false,
|
|
150
|
-
position: { start: 346, end: 360 },
|
|
151
|
-
},
|
|
152
|
-
{
|
|
153
|
-
frequency: 1,
|
|
154
|
-
representation: '"public"."team"."deleted_at"',
|
|
155
|
-
parts: [
|
|
156
|
-
{ text: "public", start: 144, quoted: true },
|
|
157
|
-
{ text: "team", start: 153, quoted: true },
|
|
158
|
-
{ text: "deleted_at", start: 160, quoted: true },
|
|
159
|
-
],
|
|
160
|
-
ignored: false,
|
|
161
|
-
position: { start: 144, end: 172 },
|
|
162
|
-
where: {
|
|
163
|
-
nulltest: "IS_NULL",
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
]);
|
|
167
|
-
const indexes = analyzer.deriveIndexes(testMetadata, indexesToCheck);
|
|
168
|
-
assert.deepStrictEqual(indexes, [
|
|
169
|
-
{
|
|
170
|
-
schema: "public",
|
|
171
|
-
table: "team_user",
|
|
172
|
-
column: "team_id",
|
|
173
|
-
where: {
|
|
174
|
-
nulltest: "IS_NOT_NULL",
|
|
175
|
-
},
|
|
176
|
-
},
|
|
177
|
-
{ schema: "public", table: "team", column: "team_id" },
|
|
178
|
-
{ schema: "public", table: "team_user", column: "user_id" },
|
|
179
|
-
{
|
|
180
|
-
schema: "public",
|
|
181
|
-
table: "team",
|
|
182
|
-
column: "deleted_at",
|
|
183
|
-
where: {
|
|
184
|
-
nulltest: "IS_NULL",
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
]);
|
|
188
|
-
});
|
|
189
|
-
const testMetadata = JSON.parse(fs.readFileSync("test/umami_test.json", "utf-8"));
|
|
190
|
-
test("analyzer with aliases", async function () {
|
|
191
|
-
const analyzer = new Analyzer(parse);
|
|
192
|
-
const query = dedent `
|
|
193
|
-
select
|
|
194
|
-
"public"."team_user"."team_user_id",
|
|
195
|
-
"public"."team_user"."team_id",
|
|
196
|
-
"public"."team_user"."user_id",
|
|
197
|
-
"public"."team_user"."role",
|
|
198
|
-
"public"."team_user"."created_at",
|
|
199
|
-
"public"."team_user"."updated_at"
|
|
200
|
-
from
|
|
201
|
-
"public"."team_user"
|
|
202
|
-
left join "public"."user" as "j0" on ("j0"."user_id") = ("public"."team_user"."user_id")
|
|
203
|
-
where
|
|
204
|
-
(
|
|
205
|
-
"public"."team_user"."team_id" = $1
|
|
206
|
-
and (
|
|
207
|
-
"j0"."deleted_at" is null
|
|
208
|
-
and ("j0"."user_id" is not null)
|
|
209
|
-
)
|
|
210
|
-
)
|
|
211
|
-
order by
|
|
212
|
-
"public"."team_user"."team_user_id" asc
|
|
213
|
-
limit
|
|
214
|
-
$2
|
|
215
|
-
offset
|
|
216
|
-
$3
|
|
217
|
-
`;
|
|
218
|
-
const { indexesToCheck, ansiHighlightedQuery } = await analyzer.analyze(query);
|
|
219
|
-
const indexes = analyzer.deriveIndexes(testMetadata, indexesToCheck);
|
|
220
|
-
console.log(indexes);
|
|
221
|
-
console.log(ansiHighlightedQuery);
|
|
222
|
-
assert.deepStrictEqual(indexes, [
|
|
223
|
-
{
|
|
224
|
-
schema: "public",
|
|
225
|
-
table: "team_user",
|
|
226
|
-
column: "team_user_id",
|
|
227
|
-
sort: {
|
|
228
|
-
dir: "SORTBY_ASC",
|
|
229
|
-
nulls: "SORTBY_NULLS_DEFAULT",
|
|
230
|
-
},
|
|
231
|
-
},
|
|
232
|
-
{
|
|
233
|
-
schema: "public",
|
|
234
|
-
table: "user",
|
|
235
|
-
column: "user_id",
|
|
236
|
-
where: {
|
|
237
|
-
nulltest: "IS_NOT_NULL",
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
{
|
|
241
|
-
schema: "public",
|
|
242
|
-
table: "user",
|
|
243
|
-
column: "deleted_at",
|
|
244
|
-
where: {
|
|
245
|
-
nulltest: "IS_NULL",
|
|
246
|
-
},
|
|
247
|
-
},
|
|
248
|
-
{ schema: "public", table: "team_user", column: "team_id" },
|
|
249
|
-
{ schema: "public", table: "team_user", column: "user_id" },
|
|
250
|
-
]);
|
|
251
|
-
});
|
|
252
|
-
test("analyzer does not pickup aggregate aliases", async function () {
|
|
253
|
-
const analyzer = new Analyzer(parse);
|
|
254
|
-
const query = dedent `
|
|
255
|
-
select
|
|
256
|
-
"public"."team"."team_id",
|
|
257
|
-
"public"."team"."name",
|
|
258
|
-
"public"."team"."access_code",
|
|
259
|
-
"public"."team"."logo_url",
|
|
260
|
-
"public"."team"."created_at",
|
|
261
|
-
"public"."team"."updated_at",
|
|
262
|
-
"public"."team"."deleted_at",
|
|
263
|
-
COALESCE(
|
|
264
|
-
"aggr_selection_0_Website"."_aggr_count_website",
|
|
265
|
-
0
|
|
266
|
-
) as "_aggr_count_website",
|
|
267
|
-
COALESCE(
|
|
268
|
-
"aggr_selection_1_TeamUser"."_aggr_count_teamUser",
|
|
269
|
-
0
|
|
270
|
-
) as "_aggr_count_teamUser"
|
|
271
|
-
from
|
|
272
|
-
"public"."team"
|
|
273
|
-
left join (
|
|
274
|
-
select
|
|
275
|
-
"public"."website"."team_id",
|
|
276
|
-
COUNT(*) as "_aggr_count_website"
|
|
277
|
-
from
|
|
278
|
-
"public"."website"
|
|
279
|
-
where
|
|
280
|
-
"public"."website"."deleted_at" is null
|
|
281
|
-
group by
|
|
282
|
-
"public"."website"."team_id"
|
|
283
|
-
) as "aggr_selection_0_Website" on (
|
|
284
|
-
"public"."team"."team_id" = "aggr_selection_0_Website"."team_id"
|
|
285
|
-
)
|
|
286
|
-
left join (
|
|
287
|
-
select
|
|
288
|
-
"public"."team_user"."team_id",
|
|
289
|
-
COUNT(*) as "_aggr_count_teamUser"
|
|
290
|
-
from
|
|
291
|
-
"public"."team_user"
|
|
292
|
-
left join "public"."user" as "j0" on ("j0"."user_id") = ("public"."team_user"."user_id")
|
|
293
|
-
where
|
|
294
|
-
(
|
|
295
|
-
"j0"."deleted_at" is null
|
|
296
|
-
and ("j0"."user_id" is not null)
|
|
297
|
-
)
|
|
298
|
-
group by
|
|
299
|
-
"public"."team_user"."team_id"
|
|
300
|
-
) as "aggr_selection_1_TeamUser" on (
|
|
301
|
-
"public"."team"."team_id" = "aggr_selection_1_TeamUser"."team_id"
|
|
302
|
-
)
|
|
303
|
-
where
|
|
304
|
-
(
|
|
305
|
-
"public"."team"."deleted_at" is null
|
|
306
|
-
and exists (
|
|
307
|
-
select
|
|
308
|
-
"t1"."team_id"
|
|
309
|
-
from
|
|
310
|
-
"public"."team_user" as "t1"
|
|
311
|
-
where
|
|
312
|
-
(
|
|
313
|
-
"t1"."user_id" = $1
|
|
314
|
-
and ("public"."team"."team_id") = ("t1"."team_id")
|
|
315
|
-
and "t1"."team_id" is not null
|
|
316
|
-
)
|
|
317
|
-
)
|
|
318
|
-
)
|
|
319
|
-
order by
|
|
320
|
-
"public"."team"."team_id" asc
|
|
321
|
-
limit
|
|
322
|
-
$2
|
|
323
|
-
offset
|
|
324
|
-
$3`;
|
|
325
|
-
const { indexesToCheck } = await analyzer.analyze(query);
|
|
326
|
-
assert(!indexesToCheck.some((i) => /aggr_selection_0_Website/.test(i.representation)));
|
|
327
|
-
assert(!indexesToCheck.some((i) => /aggr_selection_1_TeamUser/.test(i.representation)));
|
|
328
|
-
});
|
|
329
|
-
test("sqlcommenter test", async function () {
|
|
330
|
-
const analyzer = new Analyzer(parse);
|
|
331
|
-
const query = dedent `
|
|
332
|
-
SELECT * FROM FOO /*action='%2Fparam*d',controller='index',framework='spring',
|
|
333
|
-
traceparent='00-5bd66ef5095369c7b0d1f8f4bd33716a-c532cb4098ac3dd2-01',
|
|
334
|
-
tracestate='congo%3Dt61rcWkgMzE%2Crojo%3D00f067aa0ba902b7'*/
|
|
335
|
-
`;
|
|
336
|
-
const { tags } = await analyzer.analyze(query);
|
|
337
|
-
assert.deepStrictEqual(tags, [
|
|
338
|
-
{ key: "action", value: "/param*d" },
|
|
339
|
-
{ key: "controller", value: "index" },
|
|
340
|
-
{ key: "framework", value: "spring" },
|
|
341
|
-
{
|
|
342
|
-
key: "traceparent",
|
|
343
|
-
value: "00-5bd66ef5095369c7b0d1f8f4bd33716a-c532cb4098ac3dd2-01",
|
|
344
|
-
},
|
|
345
|
-
{ key: "tracestate", value: "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7" },
|
|
346
|
-
]);
|
|
347
|
-
});
|
|
348
|
-
test("analyzer doesn't pick up temp table references", async function () {
|
|
349
|
-
const analyzer = new Analyzer(parse);
|
|
350
|
-
const query = dedent `
|
|
351
|
-
select * from "public"."team_user" "t0"
|
|
352
|
-
`;
|
|
353
|
-
const { referencedTables } = await analyzer.analyze(query);
|
|
354
|
-
assert.deepStrictEqual(referencedTables, ["team_user"]);
|
|
355
|
-
// also check the `as` syntax
|
|
356
|
-
const query2 = dedent `
|
|
357
|
-
select * from "public"."team_user" as "t0"
|
|
358
|
-
`;
|
|
359
|
-
const { referencedTables: referencedTables2 } = await analyzer.analyze(query2);
|
|
360
|
-
assert.deepStrictEqual(referencedTables2, ["team_user"]);
|
|
361
|
-
});
|
|
362
|
-
test("analyzer should use queries aliased as existing tables", async function () {
|
|
363
|
-
const analyzer = new Analyzer(parse);
|
|
364
|
-
const query = dedent `select * from
|
|
365
|
-
(
|
|
366
|
-
select * from "guests"
|
|
367
|
-
order by
|
|
368
|
-
"guests"."last_upload" desc,
|
|
369
|
-
"guests"."id" desc
|
|
370
|
-
limit
|
|
371
|
-
100
|
|
372
|
-
) "guests"
|
|
373
|
-
cross join lateral (
|
|
374
|
-
select * from "assets"
|
|
375
|
-
where
|
|
376
|
-
(
|
|
377
|
-
"assets"."event_id" = (
|
|
378
|
-
select
|
|
379
|
-
"id"
|
|
380
|
-
from
|
|
381
|
-
"events"
|
|
382
|
-
where
|
|
383
|
-
"events"."event_key" = '01JKCVP4M2CH34SVTQGHSW4Y5G'
|
|
384
|
-
)
|
|
385
|
-
and "assets"."uploader_id" = "guests"."id"
|
|
386
|
-
)
|
|
387
|
-
order by
|
|
388
|
-
"assets"."inserted_at" desc
|
|
389
|
-
limit
|
|
390
|
-
100
|
|
391
|
-
) "userAssets";`;
|
|
392
|
-
const { referencedTables } = await analyzer.analyze(query);
|
|
393
|
-
assert.deepStrictEqual(referencedTables, ["guests", "assets", "events"]);
|
|
394
|
-
});
|
|
395
|
-
test("nudge for found star", async function () {
|
|
396
|
-
const analyzer = new Analyzer(parse);
|
|
397
|
-
const query = dedent `
|
|
398
|
-
select * from "guest"
|
|
399
|
-
`;
|
|
400
|
-
const { nudges } = await analyzer.analyze(query);
|
|
401
|
-
// This query triggers multiple nudges: SELECT *, missing WHERE, missing LIMIT
|
|
402
|
-
assert(nudges.some(n => n.kind === "AVOID_SELECT_STAR"));
|
|
403
|
-
assert(nudges.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
404
|
-
assert(nudges.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
405
|
-
});
|
|
406
|
-
test("nudge for functions on columns in WHERE clause", async function () {
|
|
407
|
-
const analyzer = new Analyzer(parse);
|
|
408
|
-
// Test LOWER function on column
|
|
409
|
-
const query1 = dedent `
|
|
410
|
-
select name from users where LOWER(name) = 'bob' limit 10
|
|
411
|
-
`;
|
|
412
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
413
|
-
assert.deepStrictEqual(nudges1, [{ kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE" }]);
|
|
414
|
-
// Test DATE function on column
|
|
415
|
-
const query2 = dedent `
|
|
416
|
-
select id from events where DATE(created_at) = '2025-09-18' limit 5
|
|
417
|
-
`;
|
|
418
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
419
|
-
assert.deepStrictEqual(nudges2, [{ kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE" }]);
|
|
420
|
-
// Test UPPER function on column
|
|
421
|
-
const query3 = dedent `
|
|
422
|
-
select email from users where UPPER(email) LIKE 'TEST%' limit 20
|
|
423
|
-
`;
|
|
424
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
425
|
-
assert.deepStrictEqual(nudges3, [{ kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE" }]);
|
|
426
|
-
// Test function in SELECT (should NOT trigger nudge)
|
|
427
|
-
const query4 = dedent `
|
|
428
|
-
select LOWER(name) from users where id = 1 limit 1
|
|
429
|
-
`;
|
|
430
|
-
const { nudges: nudges4 } = await analyzer.analyze(query4);
|
|
431
|
-
assert.deepStrictEqual(nudges4, []);
|
|
432
|
-
// Test function with literal (should NOT trigger nudge)
|
|
433
|
-
const query5 = dedent `
|
|
434
|
-
select name from users where age > LENGTH('test') limit 100
|
|
435
|
-
`;
|
|
436
|
-
const { nudges: nudges5 } = await analyzer.analyze(query5);
|
|
437
|
-
assert.deepStrictEqual(nudges5, []);
|
|
438
|
-
});
|
|
439
|
-
test("nudge for unbounded queries", async function () {
|
|
440
|
-
const analyzer = new Analyzer(parse);
|
|
441
|
-
// Test missing WHERE clause
|
|
442
|
-
const query1 = dedent `
|
|
443
|
-
select name from users
|
|
444
|
-
`;
|
|
445
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
446
|
-
assert(nudges1.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
447
|
-
assert(nudges1.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
448
|
-
// Test missing LIMIT clause (has WHERE but no LIMIT)
|
|
449
|
-
const query2 = dedent `
|
|
450
|
-
select name from users where active = true
|
|
451
|
-
`;
|
|
452
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
453
|
-
assert(!nudges2.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
454
|
-
assert(nudges2.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
455
|
-
// Test bounded query (has both WHERE and LIMIT - should NOT trigger)
|
|
456
|
-
const query3 = dedent `
|
|
457
|
-
select name from users where active = true limit 10
|
|
458
|
-
`;
|
|
459
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
460
|
-
assert(!nudges3.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
461
|
-
assert(!nudges3.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
462
|
-
// Test subquery (should NOT trigger nudges)
|
|
463
|
-
const query4 = dedent `
|
|
464
|
-
select count(*) from (select name from users) as sub
|
|
465
|
-
`;
|
|
466
|
-
const { nudges: nudges4 } = await analyzer.analyze(query4);
|
|
467
|
-
// The outer query has no WHERE/LIMIT but queries a subquery, not a table
|
|
468
|
-
assert(!nudges4.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
469
|
-
assert(!nudges4.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
470
|
-
// Test JOIN query missing WHERE/LIMIT
|
|
471
|
-
const query5 = dedent `
|
|
472
|
-
select u.name, p.title
|
|
473
|
-
from users u
|
|
474
|
-
join posts p on u.id = p.user_id
|
|
475
|
-
`;
|
|
476
|
-
const { nudges: nudges5 } = await analyzer.analyze(query5);
|
|
477
|
-
assert(nudges5.some(n => n.kind === "MISSING_WHERE_CLAUSE"));
|
|
478
|
-
assert(nudges5.some(n => n.kind === "MISSING_LIMIT_CLAUSE"));
|
|
479
|
-
});
|
|
480
|
-
test("nudge for NULL comparison issues", async function () {
|
|
481
|
-
const analyzer = new Analyzer(parse);
|
|
482
|
-
// Test = NULL (should trigger nudge)
|
|
483
|
-
const query1 = dedent `
|
|
484
|
-
select name from users where email = NULL limit 10
|
|
485
|
-
`;
|
|
486
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
487
|
-
assert(nudges1.some(n => n.kind === "USE_IS_NULL_NOT_EQUALS"));
|
|
488
|
-
// Test != NULL (should trigger nudge)
|
|
489
|
-
const query2 = dedent `
|
|
490
|
-
select name from users where email <> NULL limit 10
|
|
491
|
-
`;
|
|
492
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
493
|
-
assert(nudges2.some(n => n.kind === "USE_IS_NULL_NOT_EQUALS"));
|
|
494
|
-
// Test IS NULL (should NOT trigger nudge)
|
|
495
|
-
const query3 = dedent `
|
|
496
|
-
select name from users where email IS NULL limit 10
|
|
497
|
-
`;
|
|
498
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
499
|
-
assert(!nudges3.some(n => n.kind === "USE_IS_NULL_NOT_EQUALS"));
|
|
500
|
-
// Test normal equality (should NOT trigger nudge)
|
|
501
|
-
const query4 = dedent `
|
|
502
|
-
select name from users where active = true limit 10
|
|
503
|
-
`;
|
|
504
|
-
const { nudges: nudges4 } = await analyzer.analyze(query4);
|
|
505
|
-
assert(!nudges4.some(n => n.kind === "USE_IS_NULL_NOT_EQUALS"));
|
|
506
|
-
});
|
|
507
|
-
test("nudge for DISTINCT usage", async function () {
|
|
508
|
-
const analyzer = new Analyzer(parse);
|
|
509
|
-
// Test DISTINCT (should trigger nudge)
|
|
510
|
-
const query1 = dedent `
|
|
511
|
-
select distinct name from users where active = true limit 10
|
|
512
|
-
`;
|
|
513
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
514
|
-
assert(nudges1.some(n => n.kind === "AVOID_DISTINCT_WITHOUT_REASON"));
|
|
515
|
-
// Test regular SELECT (should NOT trigger nudge)
|
|
516
|
-
const query2 = dedent `
|
|
517
|
-
select name from users where active = true limit 10
|
|
518
|
-
`;
|
|
519
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
520
|
-
assert(!nudges2.some(n => n.kind === "AVOID_DISTINCT_WITHOUT_REASON"));
|
|
521
|
-
});
|
|
522
|
-
test("nudge for cartesian joins", async function () {
|
|
523
|
-
const analyzer = new Analyzer(parse);
|
|
524
|
-
// Test JOIN without ON clause (should trigger nudge)
|
|
525
|
-
const query1 = dedent `
|
|
526
|
-
select u.name, p.title from users u cross join posts p where u.active = true limit 10
|
|
527
|
-
`;
|
|
528
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
529
|
-
assert(nudges1.some(n => n.kind === "MISSING_JOIN_CONDITION"));
|
|
530
|
-
// Test old-style comma join (should trigger nudge)
|
|
531
|
-
const query2 = dedent `
|
|
532
|
-
select u.name, p.title from users u, posts p where u.active = true limit 10
|
|
533
|
-
`;
|
|
534
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
535
|
-
assert(nudges2.some(n => n.kind === "MISSING_JOIN_CONDITION"));
|
|
536
|
-
// Test proper JOIN with ON clause (should NOT trigger nudge)
|
|
537
|
-
const query3 = dedent `
|
|
538
|
-
select u.name, p.title from users u join posts p on u.id = p.user_id where u.active = true limit 10
|
|
539
|
-
`;
|
|
540
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
541
|
-
assert(!nudges3.some(n => n.kind === "MISSING_JOIN_CONDITION"));
|
|
542
|
-
});
|
|
543
|
-
test("nudge for LIKE leading wildcards", async function () {
|
|
544
|
-
const analyzer = new Analyzer(parse);
|
|
545
|
-
// Test LIKE with leading wildcard (should trigger nudge)
|
|
546
|
-
const query1 = dedent `
|
|
547
|
-
select name from users where email LIKE '%@example.com' limit 10
|
|
548
|
-
`;
|
|
549
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
550
|
-
assert(nudges1.some(n => n.kind === "AVOID_LEADING_WILDCARD_LIKE"));
|
|
551
|
-
// Test ILIKE with leading wildcard (should trigger nudge)
|
|
552
|
-
const query2 = dedent `
|
|
553
|
-
select name from users where email ILIKE '%EXAMPLE' limit 10
|
|
554
|
-
`;
|
|
555
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
556
|
-
assert(nudges2.some(n => n.kind === "AVOID_LEADING_WILDCARD_LIKE"));
|
|
557
|
-
// Test LIKE without leading wildcard (should NOT trigger nudge)
|
|
558
|
-
const query3 = dedent `
|
|
559
|
-
select name from users where email LIKE 'user%@example.com' limit 10
|
|
560
|
-
`;
|
|
561
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
562
|
-
assert(!nudges3.some(n => n.kind === "AVOID_LEADING_WILDCARD_LIKE"));
|
|
563
|
-
});
|
|
564
|
-
test("nudge for multiple OR conditions", async function () {
|
|
565
|
-
const analyzer = new Analyzer(parse);
|
|
566
|
-
// Test 3+ OR conditions (should trigger nudge)
|
|
567
|
-
const query1 = dedent `
|
|
568
|
-
select name from users where status = 'active' OR status = 'pending' OR status = 'trial' limit 10
|
|
569
|
-
`;
|
|
570
|
-
const { nudges: nudges1 } = await analyzer.analyze(query1);
|
|
571
|
-
assert(nudges1.some(n => n.kind === "CONSIDER_IN_INSTEAD_OF_MANY_ORS"));
|
|
572
|
-
// Test 2 OR conditions (should NOT trigger nudge)
|
|
573
|
-
const query2 = dedent `
|
|
574
|
-
select name from users where status = 'active' OR status = 'pending' limit 10
|
|
575
|
-
`;
|
|
576
|
-
const { nudges: nudges2 } = await analyzer.analyze(query2);
|
|
577
|
-
assert(!nudges2.some(n => n.kind === "CONSIDER_IN_INSTEAD_OF_MANY_ORS"));
|
|
578
|
-
// Test IN clause (should NOT trigger nudge)
|
|
579
|
-
const query3 = dedent `
|
|
580
|
-
select name from users where status IN ('active', 'pending', 'trial') limit 10
|
|
581
|
-
`;
|
|
582
|
-
const { nudges: nudges3 } = await analyzer.analyze(query3);
|
|
583
|
-
assert(!nudges3.some(n => n.kind === "CONSIDER_IN_INSTEAD_OF_MANY_ORS"));
|
|
584
|
-
});
|
package/dist/sql/builder.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
export class PostgresQueryBuilder {
|
|
2
|
-
query;
|
|
3
|
-
commands = {};
|
|
4
|
-
isIntrospection = false;
|
|
5
|
-
explainFlags = [];
|
|
6
|
-
_preamble = 0;
|
|
7
|
-
constructor(query) {
|
|
8
|
-
this.query = query;
|
|
9
|
-
}
|
|
10
|
-
get preamble() {
|
|
11
|
-
return this._preamble;
|
|
12
|
-
}
|
|
13
|
-
static createIndex(definition, name) {
|
|
14
|
-
if (name) {
|
|
15
|
-
return new PostgresQueryBuilder(`create index "${name}" on ${definition};`);
|
|
16
|
-
}
|
|
17
|
-
return new PostgresQueryBuilder(`create index on ${definition};`);
|
|
18
|
-
}
|
|
19
|
-
enable(command, value = true) {
|
|
20
|
-
const commandString = `enable_${command}`;
|
|
21
|
-
if (value) {
|
|
22
|
-
this.commands[commandString] = "on";
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
this.commands[commandString] = "off";
|
|
26
|
-
}
|
|
27
|
-
return this;
|
|
28
|
-
}
|
|
29
|
-
withQuery(query) {
|
|
30
|
-
this.query = query;
|
|
31
|
-
return this;
|
|
32
|
-
}
|
|
33
|
-
introspect() {
|
|
34
|
-
this.isIntrospection = true;
|
|
35
|
-
return this;
|
|
36
|
-
}
|
|
37
|
-
explain(flags) {
|
|
38
|
-
this.explainFlags = flags;
|
|
39
|
-
return this;
|
|
40
|
-
}
|
|
41
|
-
build() {
|
|
42
|
-
let commands = this.generateSetCommands();
|
|
43
|
-
commands += this.generateExplain().query;
|
|
44
|
-
if (this.isIntrospection) {
|
|
45
|
-
commands += " -- @qd_introspection";
|
|
46
|
-
}
|
|
47
|
-
return commands;
|
|
48
|
-
}
|
|
49
|
-
/** Return the "set a=b" parts of the command in the query separate from the explain select ... part */
|
|
50
|
-
buildParts() {
|
|
51
|
-
const commands = this.generateSetCommands();
|
|
52
|
-
const explain = this.generateExplain();
|
|
53
|
-
this._preamble = explain.preamble;
|
|
54
|
-
if (this.isIntrospection) {
|
|
55
|
-
explain.query += " -- @qd_introspection";
|
|
56
|
-
}
|
|
57
|
-
return { commands, query: explain.query };
|
|
58
|
-
}
|
|
59
|
-
generateSetCommands() {
|
|
60
|
-
let commands = "";
|
|
61
|
-
for (const key in this.commands) {
|
|
62
|
-
const value = this.commands[key];
|
|
63
|
-
commands += `set local ${key}=${value};\n`;
|
|
64
|
-
}
|
|
65
|
-
return commands;
|
|
66
|
-
}
|
|
67
|
-
generateExplain() {
|
|
68
|
-
let query = "";
|
|
69
|
-
if (this.explainFlags.length > 0) {
|
|
70
|
-
query += `explain (${this.explainFlags.join(", ")}) `;
|
|
71
|
-
}
|
|
72
|
-
const semicolon = this.query.endsWith(";") ? "" : ";";
|
|
73
|
-
const preamble = query.length;
|
|
74
|
-
query += `${this.query}${semicolon}`;
|
|
75
|
-
return { query, preamble };
|
|
76
|
-
}
|
|
77
|
-
}
|
package/dist/sql/database.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/sql/indexes.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
}
|