@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.
Files changed (42) hide show
  1. package/dist/index.cjs +222 -97
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +218 -97
  6. package/dist/index.js.map +1 -1
  7. package/dist/optimizer/genalgo.d.ts +9 -5
  8. package/dist/optimizer/genalgo.d.ts.map +1 -1
  9. package/dist/optimizer/genalgo.js +304 -0
  10. package/dist/optimizer/statistics.d.ts +1 -24
  11. package/dist/optimizer/statistics.d.ts.map +1 -1
  12. package/dist/optimizer/statistics.js +700 -0
  13. package/dist/package.json +25 -0
  14. package/dist/sql/analyzer.d.ts +3 -3
  15. package/dist/sql/analyzer.js +270 -0
  16. package/dist/sql/analyzer.test.d.ts +2 -0
  17. package/dist/sql/analyzer.test.d.ts.map +1 -0
  18. package/dist/sql/analyzer_test.js +584 -0
  19. package/dist/sql/builder.js +77 -0
  20. package/dist/sql/database.d.ts +5 -0
  21. package/dist/sql/database.d.ts.map +1 -1
  22. package/dist/sql/database.js +20 -0
  23. package/dist/sql/indexes.d.ts +8 -0
  24. package/dist/sql/indexes.d.ts.map +1 -0
  25. package/dist/sql/indexes.js +12 -0
  26. package/dist/sql/nudges.js +241 -0
  27. package/dist/sql/permutations.test.d.ts +2 -0
  28. package/dist/sql/permutations.test.d.ts.map +1 -0
  29. package/dist/sql/permutations_test.js +53 -0
  30. package/dist/sql/pg-identifier.d.ts +9 -0
  31. package/dist/sql/pg-identifier.d.ts.map +1 -0
  32. package/dist/sql/pg-identifier.test.d.ts +2 -0
  33. package/dist/sql/pg-identifier.test.d.ts.map +1 -0
  34. package/dist/sql/walker.d.ts +2 -2
  35. package/dist/sql/walker.js +295 -0
  36. package/package.json +2 -2
  37. package/dist/index.mjs +0 -24297
  38. package/dist/index.mjs.map +0 -1
  39. package/dist/sql/schema_dump.d.ts +0 -132
  40. package/dist/sql/schema_dump.d.ts.map +0 -1
  41. package/dist/sql/trace.d.ts +0 -1
  42. 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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=permutations.test.d.ts.map
@@ -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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pg-identifier.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pg-identifier.test.d.ts","sourceRoot":"","sources":["../../src/sql/pg-identifier.test.ts"],"names":[],"mappings":""}
@@ -1,6 +1,6 @@
1
1
  import type { Node } from "@pgsql/types";
2
- import type { DiscoveredColumnReference } from "./analyzer.ts";
3
- import type { Nudge } from "./nudges.ts";
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
+ }