@query-doctor/core 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,25 +1,29 @@
1
- 'use client'
1
+ "use client";
2
2
  import { bgMagentaBright, blue, dim, gray, green, magenta, red, strikethrough, yellow } from "colorette";
3
3
  import { deparseSync } from "pgsql-deparser";
4
4
  import { z } from "zod";
5
5
  import dedent from "dedent";
6
-
7
- //#region src/sql/nudges.ts
8
- function is$1(node, kind) {
6
+ //#region src/sql/ast-utils.ts
7
+ function is(node, kind) {
9
8
  return kind in node;
10
9
  }
11
- function isANode$1(node) {
10
+ function isANode(node) {
12
11
  if (typeof node !== "object" || node === null) return false;
13
12
  const keys = Object.keys(node);
14
13
  return keys.length === 1 && /^[A-Z]/.test(keys[0]);
15
14
  }
15
+ function getNodeKind(node) {
16
+ return Object.keys(node)[0];
17
+ }
18
+ //#endregion
19
+ //#region src/sql/nudges.ts
16
20
  const findFuncCallsOnColumns = (whereClause) => {
17
21
  const nudges = [];
18
22
  Walker.shallowMatch(whereClause, "FuncCall", (node) => {
19
23
  if (node.FuncCall.args && containsColumnRef(node.FuncCall.args)) nudges.push({
20
24
  kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
21
25
  severity: "WARNING",
22
- message: "Avoid using functions on columns in WHERE clause",
26
+ message: "Avoid using functions on columns in conditions — prevents index usage",
23
27
  location: node.FuncCall.location
24
28
  });
25
29
  });
@@ -27,10 +31,18 @@ const findFuncCallsOnColumns = (whereClause) => {
27
31
  if (node.CoalesceExpr.args && containsColumnRef(node.CoalesceExpr.args)) nudges.push({
28
32
  kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
29
33
  severity: "WARNING",
30
- message: "Avoid using functions on columns in WHERE clause",
34
+ message: "Avoid using functions on columns in conditions — prevents index usage",
31
35
  location: node.CoalesceExpr.location
32
36
  });
33
37
  });
38
+ Walker.shallowMatch(whereClause, "TypeCast", (node) => {
39
+ if (node.TypeCast.arg && hasColumnRefInNode(node.TypeCast.arg)) nudges.push({
40
+ kind: "AVOID_FUNCTIONS_ON_COLUMNS_IN_WHERE",
41
+ severity: "WARNING",
42
+ message: "Avoid using functions on columns in WHERE clause",
43
+ location: node.TypeCast.location
44
+ });
45
+ });
34
46
  return nudges;
35
47
  };
36
48
  /**
@@ -39,38 +51,38 @@ const findFuncCallsOnColumns = (whereClause) => {
39
51
  */
40
52
  function parseNudges(node, stack) {
41
53
  const nudges = [];
42
- if (is$1(node, "SelectStmt")) {
54
+ if (is(node, "SelectStmt")) {
43
55
  if (node.SelectStmt.whereClause) nudges.push(...findFuncCallsOnColumns(node.SelectStmt.whereClause));
44
56
  const star = node.SelectStmt.targetList?.find((target) => {
45
- if (!(is$1(target, "ResTarget") && target.ResTarget.val && is$1(target.ResTarget.val, "ColumnRef"))) return false;
57
+ if (!(is(target, "ResTarget") && target.ResTarget.val && is(target.ResTarget.val, "ColumnRef"))) return false;
46
58
  const fields = target.ResTarget.val.ColumnRef.fields;
47
- if (!fields?.some((field) => is$1(field, "A_Star"))) return false;
59
+ if (!fields?.some((field) => is(field, "A_Star"))) return false;
48
60
  if (fields.length > 1) return false;
49
61
  return true;
50
62
  });
51
63
  if (star) {
52
64
  const fromClause = node.SelectStmt.fromClause;
53
- if (!(fromClause && fromClause.length > 0 && fromClause.every((item) => is$1(item, "RangeSubselect")))) nudges.push({
65
+ if (!(fromClause && fromClause.length > 0 && fromClause.every((item) => is(item, "RangeSubselect")))) nudges.push({
54
66
  kind: "AVOID_SELECT_STAR",
55
67
  severity: "INFO",
56
68
  message: "Avoid using SELECT *",
57
69
  location: star.ResTarget.location
58
70
  });
59
71
  }
60
- 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({
72
+ for (const target of node.SelectStmt.targetList ?? []) if (is(target, "ResTarget") && target.ResTarget.val && is(target.ResTarget.val, "SubLink") && target.ResTarget.val.SubLink.subLinkType === "EXPR_SUBLINK") nudges.push({
61
73
  kind: "AVOID_SCALAR_SUBQUERY_IN_SELECT",
62
74
  severity: "WARNING",
63
75
  message: "Avoid correlated scalar subqueries in SELECT; consider rewriting as a JOIN",
64
76
  location: target.ResTarget.val.SubLink.location
65
77
  });
66
78
  }
67
- if (is$1(node, "SelectStmt")) {
79
+ if (is(node, "SelectStmt")) {
68
80
  if (!stack.some((item) => item === "RangeSubselect" || item === "SubLink" || item === "CommonTableExpr")) {
69
81
  if (node.SelectStmt.fromClause && node.SelectStmt.fromClause.length > 0) {
70
82
  if (node.SelectStmt.fromClause.some((fromItem) => {
71
- return is$1(fromItem, "RangeVar") || is$1(fromItem, "JoinExpr") && hasActualTablesInJoin(fromItem);
83
+ return is(fromItem, "RangeVar") || is(fromItem, "JoinExpr") && hasActualTablesInJoin(fromItem);
72
84
  })) {
73
- const fromLocation = node.SelectStmt.fromClause.find((item) => is$1(item, "RangeVar"))?.RangeVar.location;
85
+ const fromLocation = node.SelectStmt.fromClause.find((item) => is(item, "RangeVar"))?.RangeVar.location;
74
86
  if (!node.SelectStmt.whereClause) nudges.push({
75
87
  kind: "MISSING_WHERE_CLAUSE",
76
88
  severity: "INFO",
@@ -87,12 +99,12 @@ function parseNudges(node, stack) {
87
99
  }
88
100
  }
89
101
  }
90
- if (is$1(node, "SelectStmt") && node.SelectStmt.sortClause) for (const sortItem of node.SelectStmt.sortClause) {
91
- if (!is$1(sortItem, "SortBy")) continue;
102
+ if (is(node, "SelectStmt") && node.SelectStmt.sortClause) for (const sortItem of node.SelectStmt.sortClause) {
103
+ if (!is(sortItem, "SortBy")) continue;
92
104
  const sortDir = sortItem.SortBy.sortby_dir ?? "SORTBY_DEFAULT";
93
105
  const sortNulls = sortItem.SortBy.sortby_nulls ?? "SORTBY_NULLS_DEFAULT";
94
106
  if (sortDir === "SORTBY_DESC" && sortNulls === "SORTBY_NULLS_DEFAULT") {
95
- if (sortItem.SortBy.node && is$1(sortItem.SortBy.node, "ColumnRef")) {
107
+ if (sortItem.SortBy.node && is(sortItem.SortBy.node, "ColumnRef")) {
96
108
  const sortColumnName = getLastColumnRefField(sortItem.SortBy.node);
97
109
  if (!(sortColumnName !== null && whereHasIsNotNull(node.SelectStmt.whereClause, sortColumnName))) nudges.push({
98
110
  kind: "NULLS_FIRST_IN_DESC_ORDER",
@@ -103,8 +115,8 @@ function parseNudges(node, stack) {
103
115
  }
104
116
  }
105
117
  }
106
- if (is$1(node, "A_Expr")) {
107
- 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 === "<>")) {
118
+ if (is(node, "A_Expr")) {
119
+ if (node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0 && is(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 === "<>")) {
108
120
  const leftIsNull = isNullConstant(node.A_Expr.lexpr);
109
121
  const rightIsNull = isNullConstant(node.A_Expr.rexpr);
110
122
  if (leftIsNull || rightIsNull) nudges.push({
@@ -118,7 +130,7 @@ function parseNudges(node, stack) {
118
130
  const patternString = getStringConstantValue(node.A_Expr.rexpr);
119
131
  if (patternString && patternString.startsWith("%")) {
120
132
  let stringNode;
121
- if (is$1(node.A_Expr.rexpr, "A_Const")) stringNode = node.A_Expr.rexpr.A_Const;
133
+ if (is(node.A_Expr.rexpr, "A_Const")) stringNode = node.A_Expr.rexpr.A_Const;
122
134
  nudges.push({
123
135
  kind: "AVOID_LEADING_WILDCARD_LIKE",
124
136
  severity: "WARNING",
@@ -128,48 +140,52 @@ function parseNudges(node, stack) {
128
140
  }
129
141
  }
130
142
  }
131
- if (is$1(node, "SelectStmt") && node.SelectStmt.sortClause) {
132
- 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({
143
+ if (is(node, "SelectStmt") && node.SelectStmt.sortClause) {
144
+ for (const sortItem of node.SelectStmt.sortClause) if (is(sortItem, "SortBy") && sortItem.SortBy.node && is(sortItem.SortBy.node, "FuncCall") && sortItem.SortBy.node.FuncCall.funcname?.some((name) => is(name, "String") && name.String.sval === "random")) nudges.push({
133
145
  kind: "AVOID_ORDER_BY_RANDOM",
134
146
  severity: "WARNING",
135
147
  message: "Avoid using ORDER BY random()",
136
148
  location: sortItem.SortBy.node.FuncCall.location
137
149
  });
138
150
  }
139
- if (is$1(node, "SelectStmt") && node.SelectStmt.distinctClause) nudges.push({
151
+ if (is(node, "SelectStmt") && node.SelectStmt.distinctClause) nudges.push({
140
152
  kind: "AVOID_DISTINCT_WITHOUT_REASON",
141
153
  severity: "WARNING",
142
154
  message: "Avoid using DISTINCT without a reason"
143
155
  });
144
- if (is$1(node, "JoinExpr")) {
145
- if (!node.JoinExpr.quals) nudges.push({
156
+ if (is(node, "JoinExpr")) {
157
+ if (node.JoinExpr.quals) nudges.push(...findFuncCallsOnColumns(node.JoinExpr.quals));
158
+ else if (!node.JoinExpr.usingClause) nudges.push({
146
159
  kind: "MISSING_JOIN_CONDITION",
147
160
  severity: "WARNING",
148
161
  message: "Missing JOIN condition"
149
162
  });
150
163
  }
151
- if (is$1(node, "SelectStmt") && node.SelectStmt.fromClause && node.SelectStmt.fromClause.length > 1) {
152
- const tables = node.SelectStmt.fromClause.filter((item) => is$1(item, "RangeVar"));
153
- if (tables.length > 1) nudges.push({
154
- kind: "MISSING_JOIN_CONDITION",
155
- severity: "WARNING",
156
- message: "Missing JOIN condition",
157
- location: tables[1].RangeVar.location
158
- });
164
+ if (is(node, "SelectStmt") && node.SelectStmt.fromClause && node.SelectStmt.fromClause.length > 1) {
165
+ const tables = node.SelectStmt.fromClause.filter((item) => is(item, "RangeVar"));
166
+ if (tables.length > 1) {
167
+ const tableNames = new Set(tables.map((t) => t.RangeVar.alias?.aliasname || t.RangeVar.relname || ""));
168
+ if (!whereHasCrossTableEquality(node.SelectStmt.whereClause, tableNames)) nudges.push({
169
+ kind: "MISSING_JOIN_CONDITION",
170
+ severity: "WARNING",
171
+ message: "Missing JOIN condition",
172
+ location: tables[1].RangeVar.location
173
+ });
174
+ }
159
175
  }
160
- if (is$1(node, "BoolExpr") && node.BoolExpr.boolop === "OR_EXPR") {
161
- if (countBoolOrConditions(node) >= 3) nudges.push({
176
+ if (is(node, "BoolExpr") && node.BoolExpr.boolop === "OR_EXPR") {
177
+ if (countBoolOrConditions(node) >= 3 && allOrBranchesReferenceSameColumn(node)) nudges.push({
162
178
  kind: "CONSIDER_IN_INSTEAD_OF_MANY_ORS",
163
179
  severity: "WARNING",
164
180
  message: "Consider using IN instead of many ORs",
165
181
  location: node.BoolExpr.location
166
182
  });
167
183
  }
168
- if (is$1(node, "BoolExpr") && node.BoolExpr.boolop === "NOT_EXPR") {
184
+ if (is(node, "BoolExpr") && node.BoolExpr.boolop === "NOT_EXPR") {
169
185
  const args = node.BoolExpr.args;
170
186
  if (args && args.length === 1) {
171
187
  const inner = args[0];
172
- if (isANode$1(inner) && is$1(inner, "SubLink") && inner.SubLink.subLinkType === "ANY_SUBLINK") nudges.push({
188
+ if (isANode(inner) && is(inner, "SubLink") && inner.SubLink.subLinkType === "ANY_SUBLINK") nudges.push({
173
189
  kind: "PREFER_NOT_EXISTS_OVER_NOT_IN",
174
190
  severity: "WARNING",
175
191
  message: "Prefer NOT EXISTS over NOT IN (SELECT ...)",
@@ -177,21 +193,21 @@ function parseNudges(node, stack) {
177
193
  });
178
194
  }
179
195
  }
180
- if (is$1(node, "FuncCall")) {
196
+ if (is(node, "FuncCall")) {
181
197
  const funcName = node.FuncCall.funcname;
182
- 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({
198
+ if (funcName && funcName.length === 1 && is(funcName[0], "String") && funcName[0].String.sval === "count" && node.FuncCall.args && !node.FuncCall.agg_star && !node.FuncCall.agg_distinct) nudges.push({
183
199
  kind: "PREFER_COUNT_STAR_OVER_COUNT_COLUMN",
184
200
  severity: "INFO",
185
201
  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.",
186
202
  location: node.FuncCall.location
187
203
  });
188
204
  }
189
- if (is$1(node, "SelectStmt") && node.SelectStmt.havingClause) {
205
+ if (is(node, "SelectStmt") && node.SelectStmt.havingClause) {
190
206
  if (!containsAggregate(node.SelectStmt.havingClause)) {
191
207
  const having = node.SelectStmt.havingClause;
192
208
  let location;
193
- if (is$1(having, "A_Expr")) location = having.A_Expr.location;
194
- else if (is$1(having, "BoolExpr")) location = having.BoolExpr.location;
209
+ if (is(having, "A_Expr")) location = having.A_Expr.location;
210
+ else if (is(having, "BoolExpr")) location = having.BoolExpr.location;
195
211
  nudges.push({
196
212
  kind: "PREFER_WHERE_OVER_HAVING_FOR_NON_AGGREGATES",
197
213
  severity: "INFO",
@@ -200,7 +216,7 @@ function parseNudges(node, stack) {
200
216
  });
201
217
  }
202
218
  }
203
- if (is$1(node, "FuncCall")) {
219
+ if (is(node, "FuncCall")) {
204
220
  if (stack.some((item) => item === "whereClause") && node.FuncCall.args) {
205
221
  const name = getFuncName(node);
206
222
  if (name && JSONB_SET_RETURNING_FUNCTIONS.has(name) && containsColumnRef(node.FuncCall.args)) nudges.push({
@@ -211,11 +227,11 @@ function parseNudges(node, stack) {
211
227
  });
212
228
  }
213
229
  }
214
- if (is$1(node, "A_Expr")) {
230
+ if (is(node, "A_Expr")) {
215
231
  if (node.A_Expr.kind === "AEXPR_IN") {
216
232
  let list;
217
- if (node.A_Expr.lexpr && is$1(node.A_Expr.lexpr, "List")) list = node.A_Expr.lexpr.List;
218
- else if (node.A_Expr.rexpr && is$1(node.A_Expr.rexpr, "List")) list = node.A_Expr.rexpr.List;
233
+ if (node.A_Expr.lexpr && is(node.A_Expr.lexpr, "List")) list = node.A_Expr.lexpr.List;
234
+ else if (node.A_Expr.rexpr && is(node.A_Expr.rexpr, "List")) list = node.A_Expr.rexpr.List;
219
235
  if (list?.items && list.items.length >= 10) nudges.push({
220
236
  kind: "REPLACE_LARGE_IN_TUPLE_WITH_ANY_ARRAY",
221
237
  message: "`in (...)` queries with large tuples can often be replaced with `= ANY($1)` using a single parameter",
@@ -224,8 +240,8 @@ function parseNudges(node, stack) {
224
240
  });
225
241
  }
226
242
  }
227
- if (is$1(node, "FuncCall")) {
228
- const funcname = node.FuncCall.funcname?.[0] && is$1(node.FuncCall.funcname[0], "String") && node.FuncCall.funcname[0].String.sval;
243
+ if (is(node, "FuncCall")) {
244
+ const funcname = node.FuncCall.funcname?.[0] && is(node.FuncCall.funcname[0], "String") && node.FuncCall.funcname[0].String.sval;
229
245
  if (funcname && [
230
246
  "sum",
231
247
  "count",
@@ -234,11 +250,11 @@ function parseNudges(node, stack) {
234
250
  "max"
235
251
  ].includes(funcname.toLowerCase())) {
236
252
  const firstArg = node.FuncCall.args?.[0];
237
- if (firstArg && isANode$1(firstArg) && is$1(firstArg, "CaseExpr")) {
253
+ if (firstArg && isANode(firstArg) && is(firstArg, "CaseExpr")) {
238
254
  const caseExpr = firstArg.CaseExpr;
239
255
  if (caseExpr.args && caseExpr.args.length === 1) {
240
256
  const defresult = caseExpr.defresult;
241
- 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({
257
+ if (!defresult || isANode(defresult) && is(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({
242
258
  kind: "PREFER_FILTER_OVER_CASE_IN_AGGREGATE",
243
259
  severity: "INFO",
244
260
  message: "Use FILTER (WHERE ...) instead of CASE inside aggregate functions",
@@ -248,14 +264,14 @@ function parseNudges(node, stack) {
248
264
  }
249
265
  }
250
266
  }
251
- if (is$1(node, "SelectStmt") && node.SelectStmt.op === "SETOP_UNION" && !node.SelectStmt.all) nudges.push({
267
+ if (is(node, "SelectStmt") && node.SelectStmt.op === "SETOP_UNION" && !node.SelectStmt.all) nudges.push({
252
268
  kind: "PREFER_UNION_ALL_OVER_UNION",
253
269
  severity: "INFO",
254
270
  message: "UNION removes duplicates with an implicit sort — use UNION ALL if deduplication is not needed"
255
271
  });
256
- if (is$1(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0) {
272
+ if (is(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP" && node.A_Expr.name && node.A_Expr.name.length > 0) {
257
273
  const opNode = node.A_Expr.name[0];
258
- const op = is$1(opNode, "String") ? opNode.String.sval : null;
274
+ const op = is(opNode, "String") ? opNode.String.sval : null;
259
275
  if (op && isExistenceCheckPattern(node.A_Expr.lexpr, node.A_Expr.rexpr, op)) nudges.push({
260
276
  kind: "USE_EXISTS_NOT_COUNT_FOR_EXISTENCE_CHECK",
261
277
  severity: "INFO",
@@ -270,32 +286,32 @@ function containsColumnRef(args) {
270
286
  return false;
271
287
  }
272
288
  function hasColumnRefInNode(node) {
273
- if (isANode$1(node) && is$1(node, "ColumnRef")) return true;
289
+ if (isANode(node) && is(node, "ColumnRef")) return true;
274
290
  if (typeof node !== "object" || node === null) return false;
275
291
  if (Array.isArray(node)) return node.some((item) => hasColumnRefInNode(item));
276
- if (isANode$1(node)) return hasColumnRefInNode(node[Object.keys(node)[0]]);
292
+ if (isANode(node)) return hasColumnRefInNode(node[Object.keys(node)[0]]);
277
293
  for (const child of Object.values(node)) if (hasColumnRefInNode(child)) return true;
278
294
  return false;
279
295
  }
280
296
  function hasActualTablesInJoin(joinExpr) {
281
- if (joinExpr.JoinExpr.larg && is$1(joinExpr.JoinExpr.larg, "RangeVar")) return true;
282
- if (joinExpr.JoinExpr.larg && is$1(joinExpr.JoinExpr.larg, "JoinExpr")) {
297
+ if (joinExpr.JoinExpr.larg && is(joinExpr.JoinExpr.larg, "RangeVar")) return true;
298
+ if (joinExpr.JoinExpr.larg && is(joinExpr.JoinExpr.larg, "JoinExpr")) {
283
299
  if (hasActualTablesInJoin(joinExpr.JoinExpr.larg)) return true;
284
300
  }
285
- if (joinExpr.JoinExpr.rarg && is$1(joinExpr.JoinExpr.rarg, "RangeVar")) return true;
286
- if (joinExpr.JoinExpr.rarg && is$1(joinExpr.JoinExpr.rarg, "JoinExpr")) {
301
+ if (joinExpr.JoinExpr.rarg && is(joinExpr.JoinExpr.rarg, "RangeVar")) return true;
302
+ if (joinExpr.JoinExpr.rarg && is(joinExpr.JoinExpr.rarg, "JoinExpr")) {
287
303
  if (hasActualTablesInJoin(joinExpr.JoinExpr.rarg)) return true;
288
304
  }
289
305
  return false;
290
306
  }
291
307
  function isNullConstant(node) {
292
308
  if (!node || typeof node !== "object") return false;
293
- if (isANode$1(node) && is$1(node, "A_Const")) return node.A_Const.isnull !== void 0;
309
+ if (isANode(node) && is(node, "A_Const")) return node.A_Const.isnull !== void 0;
294
310
  return false;
295
311
  }
296
312
  function getStringConstantValue(node) {
297
313
  if (!node || typeof node !== "object") return null;
298
- if (isANode$1(node) && is$1(node, "A_Const") && node.A_Const.sval) return node.A_Const.sval.sval || null;
314
+ if (isANode(node) && is(node, "A_Const") && node.A_Const.sval) return node.A_Const.sval.sval || null;
299
315
  return null;
300
316
  }
301
317
  const JSONB_SET_RETURNING_FUNCTIONS = new Set([
@@ -312,21 +328,21 @@ function getFuncName(node) {
312
328
  const names = node.FuncCall.funcname;
313
329
  if (!names || names.length === 0) return null;
314
330
  const last = names[names.length - 1];
315
- if (isANode$1(last) && is$1(last, "String") && last.String.sval) return last.String.sval;
331
+ if (isANode(last) && is(last, "String") && last.String.sval) return last.String.sval;
316
332
  return null;
317
333
  }
318
334
  function getLastColumnRefField(columnRef) {
319
335
  const fields = columnRef.ColumnRef.fields;
320
336
  if (!fields || fields.length === 0) return null;
321
337
  const lastField = fields[fields.length - 1];
322
- if (isANode$1(lastField) && is$1(lastField, "String")) return lastField.String.sval || null;
338
+ if (isANode(lastField) && is(lastField, "String")) return lastField.String.sval || null;
323
339
  return null;
324
340
  }
325
341
  function whereHasIsNotNull(whereClause, columnName) {
326
342
  if (!whereClause) return false;
327
343
  let found = false;
328
344
  Walker.shallowMatch(whereClause, "NullTest", (node) => {
329
- if (node.NullTest.nulltesttype === "IS_NOT_NULL" && node.NullTest.arg && is$1(node.NullTest.arg, "ColumnRef")) {
345
+ if (node.NullTest.nulltesttype === "IS_NOT_NULL" && node.NullTest.arg && is(node.NullTest.arg, "ColumnRef")) {
330
346
  if (getLastColumnRefField(node.NullTest.arg) === columnName) found = true;
331
347
  }
332
348
  });
@@ -347,41 +363,106 @@ const AGGREGATE_FUNCTIONS = new Set([
347
363
  function containsAggregate(node) {
348
364
  if (!node || typeof node !== "object") return false;
349
365
  if (Array.isArray(node)) return node.some(containsAggregate);
350
- if (isANode$1(node) && is$1(node, "FuncCall")) {
366
+ if (isANode(node) && is(node, "FuncCall")) {
351
367
  const funcname = node.FuncCall.funcname;
352
368
  if (funcname) {
353
- for (const f of funcname) if (isANode$1(f) && is$1(f, "String") && AGGREGATE_FUNCTIONS.has(f.String.sval?.toLowerCase() ?? "")) return true;
369
+ for (const f of funcname) if (isANode(f) && is(f, "String") && AGGREGATE_FUNCTIONS.has(f.String.sval?.toLowerCase() ?? "")) return true;
354
370
  }
355
371
  }
356
- if (isANode$1(node)) return containsAggregate(node[Object.keys(node)[0]]);
372
+ if (isANode(node)) return containsAggregate(node[Object.keys(node)[0]]);
357
373
  for (const child of Object.values(node)) if (containsAggregate(child)) return true;
358
374
  return false;
359
375
  }
360
376
  function countBoolOrConditions(node) {
361
377
  if (node.BoolExpr.boolop !== "OR_EXPR" || !node.BoolExpr.args) return 1;
362
378
  let count = 0;
363
- for (const arg of node.BoolExpr.args) if (isANode$1(arg) && is$1(arg, "BoolExpr") && arg.BoolExpr.boolop === "OR_EXPR") count += countBoolOrConditions(arg);
379
+ for (const arg of node.BoolExpr.args) if (isANode(arg) && is(arg, "BoolExpr") && arg.BoolExpr.boolop === "OR_EXPR") count += countBoolOrConditions(arg);
364
380
  else count += 1;
365
381
  return count;
366
382
  }
383
+ /**
384
+ * Check whether every leaf of a top-level OR expression references the same
385
+ * left-hand column (e.g. `status = 'a' OR status = 'b' OR status = 'c'`).
386
+ * Returns false when ORs span different columns — IN rewrite doesn't apply.
387
+ */
388
+ function allOrBranchesReferenceSameColumn(node) {
389
+ const columns = collectOrLeafColumns(node);
390
+ if (columns.length === 0) return false;
391
+ return columns.every((col) => col === columns[0]);
392
+ }
393
+ function collectOrLeafColumns(node) {
394
+ if (node.BoolExpr.boolop !== "OR_EXPR" || !node.BoolExpr.args) return [];
395
+ const columns = [];
396
+ for (const arg of node.BoolExpr.args) {
397
+ if (!isANode(arg)) continue;
398
+ if (is(arg, "BoolExpr") && arg.BoolExpr.boolop === "OR_EXPR") columns.push(...collectOrLeafColumns(arg));
399
+ else if (is(arg, "A_Expr")) {
400
+ const col = getLeftColumnKey(arg);
401
+ if (col) columns.push(col);
402
+ else return [];
403
+ } else return [];
404
+ }
405
+ return columns;
406
+ }
407
+ /**
408
+ * Get a string key for the left-hand column of an A_Expr equality comparison.
409
+ * For `t1.col = value` returns `"t1.col"`, for `col = value` returns `"col"`.
410
+ */
411
+ function getLeftColumnKey(expr) {
412
+ if (!expr.A_Expr.lexpr || !isANode(expr.A_Expr.lexpr)) return null;
413
+ if (!is(expr.A_Expr.lexpr, "ColumnRef")) return null;
414
+ const fields = expr.A_Expr.lexpr.ColumnRef.fields;
415
+ if (!fields) return null;
416
+ return fields.filter((f) => isANode(f) && is(f, "String")).map((f) => f.String.sval).join(".");
417
+ }
418
+ /**
419
+ * Check if a WHERE clause contains an equality between columns from different
420
+ * tables (indicating an old-style implicit join condition).
421
+ */
422
+ function whereHasCrossTableEquality(whereClause, tableNames) {
423
+ if (!whereClause) return false;
424
+ if (isANode(whereClause) && is(whereClause, "A_Expr")) {
425
+ if (whereClause.A_Expr.kind === "AEXPR_OP" && whereClause.A_Expr.name?.some((n) => is(n, "String") && n.String.sval === "=")) {
426
+ const leftTable = getColumnRefTableQualifier(whereClause.A_Expr.lexpr);
427
+ const rightTable = getColumnRefTableQualifier(whereClause.A_Expr.rexpr);
428
+ if (leftTable && rightTable && leftTable !== rightTable && tableNames.has(leftTable) && tableNames.has(rightTable)) return true;
429
+ }
430
+ }
431
+ if (isANode(whereClause) && is(whereClause, "BoolExpr")) {
432
+ for (const arg of whereClause.BoolExpr.args ?? []) if (isANode(arg) && whereHasCrossTableEquality(arg, tableNames)) return true;
433
+ }
434
+ return false;
435
+ }
436
+ /**
437
+ * Extract the table qualifier (first field) from a ColumnRef node.
438
+ * e.g. `t1.uid` → `"t1"`, `uid` → null
439
+ */
440
+ function getColumnRefTableQualifier(node) {
441
+ if (!node || !isANode(node) || !is(node, "ColumnRef")) return null;
442
+ const fields = node.ColumnRef.fields;
443
+ if (!fields || fields.length < 2) return null;
444
+ const first = fields[0];
445
+ if (isANode(first) && is(first, "String")) return first.String.sval || null;
446
+ return null;
447
+ }
367
448
  function isCountFuncCall(node) {
368
449
  if (!node || typeof node !== "object") return false;
369
- if (!isANode$1(node) || !is$1(node, "FuncCall")) return false;
450
+ if (!isANode(node) || !is(node, "FuncCall")) return false;
370
451
  const fc = node.FuncCall;
371
- if (!(fc.funcname?.some((n) => is$1(n, "String") && n.String.sval === "count") ?? false)) return false;
452
+ if (!(fc.funcname?.some((n) => is(n, "String") && n.String.sval === "count") ?? false)) return false;
372
453
  if (fc.agg_star) return true;
373
- if (fc.args && fc.args.length === 1 && isANode$1(fc.args[0]) && is$1(fc.args[0], "A_Const")) return true;
454
+ if (fc.args && fc.args.length === 1 && isANode(fc.args[0]) && is(fc.args[0], "A_Const")) return true;
374
455
  return false;
375
456
  }
376
457
  function isSubLinkWithCount(node) {
377
458
  if (!node || typeof node !== "object") return false;
378
- if (!isANode$1(node) || !is$1(node, "SubLink")) return false;
459
+ if (!isANode(node) || !is(node, "SubLink")) return false;
379
460
  const subselect = node.SubLink.subselect;
380
- if (!subselect || !isANode$1(subselect) || !is$1(subselect, "SelectStmt")) return false;
461
+ if (!subselect || !isANode(subselect) || !is(subselect, "SelectStmt")) return false;
381
462
  const targets = subselect.SelectStmt.targetList;
382
463
  if (!targets || targets.length !== 1) return false;
383
464
  const target = targets[0];
384
- if (!isANode$1(target) || !is$1(target, "ResTarget") || !target.ResTarget.val) return false;
465
+ if (!isANode(target) || !is(target, "ResTarget") || !target.ResTarget.val) return false;
385
466
  return isCountFuncCall(target.ResTarget.val);
386
467
  }
387
468
  function isCountExpression(node) {
@@ -389,7 +470,7 @@ function isCountExpression(node) {
389
470
  }
390
471
  function getIntegerConstantValue(node) {
391
472
  if (!node || typeof node !== "object") return null;
392
- if (!isANode$1(node) || !is$1(node, "A_Const")) return null;
473
+ if (!isANode(node) || !is(node, "A_Const")) return null;
393
474
  if (node.A_Const.ival === void 0) return null;
394
475
  return node.A_Const.ival.ival ?? 0;
395
476
  }
@@ -412,9 +493,8 @@ function isExistenceCheckPattern(lexpr, rexpr, op) {
412
493
  }
413
494
  return false;
414
495
  }
415
-
416
496
  //#endregion
417
- //#region \0@oxc-project+runtime@0.112.0/helpers/typeof.js
497
+ //#region \0@oxc-project+runtime@0.122.0/helpers/typeof.js
418
498
  function _typeof(o) {
419
499
  "@babel/helpers - typeof";
420
500
  return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(o) {
@@ -423,9 +503,8 @@ function _typeof(o) {
423
503
  return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o;
424
504
  }, _typeof(o);
425
505
  }
426
-
427
506
  //#endregion
428
- //#region \0@oxc-project+runtime@0.112.0/helpers/toPrimitive.js
507
+ //#region \0@oxc-project+runtime@0.122.0/helpers/toPrimitive.js
429
508
  function toPrimitive(t, r) {
430
509
  if ("object" != _typeof(t) || !t) return t;
431
510
  var e = t[Symbol.toPrimitive];
@@ -436,16 +515,14 @@ function toPrimitive(t, r) {
436
515
  }
437
516
  return ("string" === r ? String : Number)(t);
438
517
  }
439
-
440
518
  //#endregion
441
- //#region \0@oxc-project+runtime@0.112.0/helpers/toPropertyKey.js
519
+ //#region \0@oxc-project+runtime@0.122.0/helpers/toPropertyKey.js
442
520
  function toPropertyKey(t) {
443
521
  var i = toPrimitive(t, "string");
444
522
  return "symbol" == _typeof(i) ? i : i + "";
445
523
  }
446
-
447
524
  //#endregion
448
- //#region \0@oxc-project+runtime@0.112.0/helpers/defineProperty.js
525
+ //#region \0@oxc-project+runtime@0.122.0/helpers/defineProperty.js
449
526
  function _defineProperty(e, r, t) {
450
527
  return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {
451
528
  value: t,
@@ -454,7 +531,6 @@ function _defineProperty(e, r, t) {
454
531
  writable: !0
455
532
  }) : e[r] = t, e;
456
533
  }
457
-
458
534
  //#endregion
459
535
  //#region src/sql/walker.ts
460
536
  const JSONB_EXTRACTION_OPS = new Set(["->", "->>"]);
@@ -701,17 +777,6 @@ var Walker = class Walker {
701
777
  } else for (const [key, child] of Object.entries(node)) Walker.doTraverse(child, [...stack, key], callback);
702
778
  }
703
779
  };
704
- function is(node, kind) {
705
- return kind in node;
706
- }
707
- function getNodeKind(node) {
708
- return Object.keys(node)[0];
709
- }
710
- function isANode(node) {
711
- if (typeof node !== "object" || node === null) return false;
712
- const keys = Object.keys(node);
713
- return keys.length === 1 && /^[A-Z]/.test(keys[0]);
714
- }
715
780
  /**
716
781
  * Given an operand of a comparison (e.g. the left side of `=`), check whether
717
782
  * it is a JSONB path extraction expression such as `data->>'email'` or
@@ -763,7 +828,6 @@ function stripTableQualifiers(node) {
763
828
  for (const value of Object.values(node)) if (Array.isArray(value)) for (const item of value) stripTableQualifiers(item);
764
829
  else if (typeof value === "object" && value !== null) stripTableQualifiers(value);
765
830
  }
766
-
767
831
  //#endregion
768
832
  //#region src/sql/analyzer.ts
769
833
  const ignoredIdentifier = "__qd_placeholder";
@@ -989,10 +1053,17 @@ var Analyzer = class {
989
1053
  };
990
1054
  }
991
1055
  };
992
-
993
1056
  //#endregion
994
1057
  //#region src/sql/database.ts
995
1058
  const PostgresVersion = z.string().brand("PostgresVersion");
1059
+ /** Zod schema for PostgresExplainStage — validates common fields, passes through variant-specific ones. */
1060
+ const PostgresExplainStageSchema = z.lazy(() => z.object({
1061
+ "Node Type": z.string(),
1062
+ "Total Cost": z.number(),
1063
+ "Plan Width": z.number(),
1064
+ "Node Id": z.number().optional(),
1065
+ Plans: z.array(PostgresExplainStageSchema).optional()
1066
+ }).passthrough());
996
1067
  /**
997
1068
  * Drops a disabled index. Rollsback if it fails for any reason
998
1069
  * @returns Did dropping the index succeed?
@@ -1009,7 +1080,6 @@ async function dropIndex(tx, index) {
1009
1080
  return false;
1010
1081
  }
1011
1082
  }
1012
-
1013
1083
  //#endregion
1014
1084
  //#region src/sql/indexes.ts
1015
1085
  function isIndexSupported(index) {
@@ -1022,7 +1092,6 @@ function isIndexSupported(index) {
1022
1092
  function isIndexProbablyDroppable(index) {
1023
1093
  return !index.is_primary && !index.is_unique;
1024
1094
  }
1025
-
1026
1095
  //#endregion
1027
1096
  //#region src/sql/builder.ts
1028
1097
  var PostgresQueryBuilder = class PostgresQueryBuilder {
@@ -1112,7 +1181,6 @@ var PostgresQueryBuilder = class PostgresQueryBuilder {
1112
1181
  return query;
1113
1182
  }
1114
1183
  };
1115
-
1116
1184
  //#endregion
1117
1185
  //#region src/sql/pg-identifier.ts
1118
1186
  /**
@@ -1324,7 +1392,6 @@ _defineProperty(PgIdentifier, "reservedKeywords", new Set([
1324
1392
  "xmlserialize",
1325
1393
  "xmltable"
1326
1394
  ]));
1327
-
1328
1395
  //#endregion
1329
1396
  //#region src/sql/permutations.ts
1330
1397
  /**
@@ -1349,7 +1416,6 @@ function permutationsWithDescendingLength(arr) {
1349
1416
  collected.sort((a, b) => b.length - a.length);
1350
1417
  return collected;
1351
1418
  }
1352
-
1353
1419
  //#endregion
1354
1420
  //#region src/optimizer/genalgo.ts
1355
1421
  var IndexOptimizer = class IndexOptimizer {
@@ -1688,7 +1754,6 @@ var RollbackError = class {
1688
1754
  };
1689
1755
  const PROCEED = Symbol("PROCEED");
1690
1756
  const SKIP = Symbol("SKIP");
1691
-
1692
1757
  //#endregion
1693
1758
  //#region src/optimizer/statistics.ts
1694
1759
  const StatisticsSource = z.union([z.object({
@@ -2328,7 +2393,6 @@ _defineProperty(Statistics, "defaultStatsMode", Object.freeze({
2328
2393
  reltuples: DEFAULT_RELTUPLES,
2329
2394
  relpages: DEFAULT_RELPAGES
2330
2395
  }));
2331
-
2332
2396
  //#endregion
2333
2397
  //#region src/optimizer/pss-rewriter.ts
2334
2398
  /**
@@ -2355,7 +2419,7 @@ var PssRewriter = class {
2355
2419
  });
2356
2420
  }
2357
2421
  };
2358
-
2359
2422
  //#endregion
2360
- export { Analyzer, ExportedStats, ExportedStatsColumns, ExportedStatsIndex, ExportedStatsStatistics, ExportedStatsV1, IndexOptimizer, PROCEED, PgIdentifier, PostgresQueryBuilder, PostgresVersion, PssRewriter, SKIP, Statistics, StatisticsMode, StatisticsSource, dropIndex, ignoredIdentifier, isIndexProbablyDroppable, isIndexSupported, parseNudges };
2423
+ export { Analyzer, ExportedStats, ExportedStatsColumns, ExportedStatsIndex, ExportedStatsStatistics, ExportedStatsV1, IndexOptimizer, PROCEED, PgIdentifier, PostgresExplainStageSchema, PostgresQueryBuilder, PostgresVersion, PssRewriter, SKIP, Statistics, StatisticsMode, StatisticsSource, dropIndex, ignoredIdentifier, isIndexProbablyDroppable, isIndexSupported, parseNudges };
2424
+
2361
2425
  //# sourceMappingURL=index.mjs.map