@query-doctor/core 0.2.5 → 0.4.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.cjs CHANGED
@@ -392,6 +392,18 @@ var Walker = class _Walker {
392
392
  }
393
393
  }
394
394
  }
395
+ if (is2(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP") {
396
+ const opName = node.A_Expr.name?.[0] && is2(node.A_Expr.name[0], "String") && node.A_Expr.name[0].String.sval;
397
+ if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&")) {
398
+ const jsonbOperator = opName;
399
+ if (node.A_Expr.lexpr && is2(node.A_Expr.lexpr, "ColumnRef")) {
400
+ this.add(node.A_Expr.lexpr, { jsonbOperator });
401
+ }
402
+ if (node.A_Expr.rexpr && is2(node.A_Expr.rexpr, "ColumnRef")) {
403
+ this.add(node.A_Expr.rexpr, { jsonbOperator });
404
+ }
405
+ }
406
+ }
395
407
  if (is2(node, "ColumnRef")) {
396
408
  for (let i = 0; i < stack.length; i++) {
397
409
  const inReturningList = stack[i] === "returningList" && stack[i + 1] === "ResTarget" && stack[i + 2] === "val" && stack[i + 3] === "ColumnRef";
@@ -490,6 +502,9 @@ var Walker = class _Walker {
490
502
  if (options?.where) {
491
503
  ref.where = options.where;
492
504
  }
505
+ if (options?.jsonbOperator) {
506
+ ref.jsonbOperator = options.jsonbOperator;
507
+ }
493
508
  this.highlights.push(ref);
494
509
  }
495
510
  static traverse(node, stack, callback) {
@@ -654,6 +669,9 @@ var Analyzer = class {
654
669
  if (colReference.where) {
655
670
  index.where = colReference.where;
656
671
  }
672
+ if (colReference.jsonbOperator) {
673
+ index.jsonbOperator = colReference.jsonbOperator;
674
+ }
657
675
  addIndex(index);
658
676
  }
659
677
  } else if (tableReference) {
@@ -675,6 +693,9 @@ var Analyzer = class {
675
693
  if (colReference.where) {
676
694
  index.where = colReference.where;
677
695
  }
696
+ if (colReference.jsonbOperator) {
697
+ index.jsonbOperator = colReference.jsonbOperator;
698
+ }
678
699
  addIndex(index);
679
700
  }
680
701
  } else if (fullReference) {
@@ -693,6 +714,9 @@ var Analyzer = class {
693
714
  if (colReference.where) {
694
715
  index.where = colReference.where;
695
716
  }
717
+ if (colReference.jsonbOperator) {
718
+ index.jsonbOperator = colReference.jsonbOperator;
719
+ }
696
720
  addIndex(index);
697
721
  } else {
698
722
  console.error(
@@ -815,7 +839,7 @@ async function dropIndex(tx, index) {
815
839
 
816
840
  // src/sql/indexes.ts
817
841
  function isIndexSupported(index) {
818
- return index.index_type === "btree";
842
+ return index.index_type === "btree" || index.index_type === "gin";
819
843
  }
820
844
  function isIndexProbablyDroppable(index) {
821
845
  return !index.is_primary && !index.is_unique;
@@ -829,6 +853,9 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
829
853
  __publicField(this, "isIntrospection", false);
830
854
  __publicField(this, "explainFlags", []);
831
855
  __publicField(this, "_preamble", 0);
856
+ __publicField(this, "parameters", {});
857
+ // substitution for `limit $1` -> `limit 5`
858
+ __publicField(this, "limitSubstitution");
832
859
  }
833
860
  get preamble() {
834
861
  return this._preamble;
@@ -862,6 +889,14 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
862
889
  this.explainFlags = flags;
863
890
  return this;
864
891
  }
892
+ parameterize(parameters) {
893
+ Object.assign(this.parameters, parameters);
894
+ return this;
895
+ }
896
+ replaceLimit(limit) {
897
+ this.limitSubstitution = limit;
898
+ return this;
899
+ }
865
900
  build() {
866
901
  let commands = this.generateSetCommands();
867
902
  commands += this.generateExplain().query;
@@ -890,14 +925,28 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
890
925
  return commands;
891
926
  }
892
927
  generateExplain() {
893
- let query = "";
928
+ let finalQuery = "";
894
929
  if (this.explainFlags.length > 0) {
895
- query += `explain (${this.explainFlags.join(", ")}) `;
930
+ finalQuery += `explain (${this.explainFlags.join(", ")}) `;
931
+ }
932
+ const query = this.substituteQuery();
933
+ const semicolon = query.endsWith(";") ? "" : ";";
934
+ const preamble = finalQuery.length;
935
+ finalQuery += `${query}${semicolon}`;
936
+ return { query: finalQuery, preamble };
937
+ }
938
+ substituteQuery() {
939
+ let query = this.query;
940
+ if (this.limitSubstitution !== void 0) {
941
+ query = query.replace(
942
+ /limit\s+\$\d+/g,
943
+ `limit ${this.limitSubstitution}`
944
+ );
945
+ }
946
+ for (const [key, value] of Object.entries(this.parameters)) {
947
+ query = query.replaceAll(`\\$${key}`, value.toString());
896
948
  }
897
- const semicolon = this.query.endsWith(";") ? "" : ";";
898
- const preamble = query.length;
899
- query += `${this.query}${semicolon}`;
900
- return { query, preamble };
949
+ return query;
901
950
  }
902
951
  };
903
952
 
@@ -1272,8 +1321,10 @@ var _IndexOptimizer = class _IndexOptimizer {
1272
1321
  * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1273
1322
  **/
1274
1323
  indexesToCreate(rootCandidates) {
1275
- const permutedIndexes = this.groupPotentialIndexColumnsByTable(rootCandidates);
1324
+ const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator);
1325
+ const ginCandidates = rootCandidates.filter((c) => c.jsonbOperator);
1276
1326
  const nextStage = [];
1327
+ const permutedIndexes = this.groupPotentialIndexColumnsByTable(btreeCandidates);
1277
1328
  for (const permutation of permutedIndexes.values()) {
1278
1329
  const { table: rawTable, schema: rawSchema, columns } = permutation;
1279
1330
  const permutations = permutationsWithDescendingLength(columns);
@@ -1298,6 +1349,41 @@ var _IndexOptimizer = class _IndexOptimizer {
1298
1349
  });
1299
1350
  }
1300
1351
  }
1352
+ const ginGroups = this.groupGinCandidatesByColumn(ginCandidates);
1353
+ for (const group of ginGroups.values()) {
1354
+ const { schema: rawSchema, table: rawTable, column, operators } = group;
1355
+ const schema = PgIdentifier.fromString(rawSchema);
1356
+ const table = PgIdentifier.fromString(rawTable);
1357
+ const needsKeyExistence = operators.some(
1358
+ (op) => op === "?" || op === "?|" || op === "?&"
1359
+ );
1360
+ const opclass = needsKeyExistence ? void 0 : "jsonb_path_ops";
1361
+ const existingGin = this.ginIndexAlreadyExists(table.toString(), column);
1362
+ if (existingGin) {
1363
+ continue;
1364
+ }
1365
+ const indexName = this.indexName();
1366
+ const candidate = {
1367
+ schema: rawSchema,
1368
+ table: rawTable,
1369
+ column
1370
+ };
1371
+ const definition = this.toGinDefinition({
1372
+ table,
1373
+ schema,
1374
+ column: PgIdentifier.fromString(column),
1375
+ opclass
1376
+ });
1377
+ nextStage.push({
1378
+ name: indexName,
1379
+ schema: schema.toString(),
1380
+ table: table.toString(),
1381
+ columns: [candidate],
1382
+ definition,
1383
+ indexMethod: "gin",
1384
+ opclass
1385
+ });
1386
+ }
1301
1387
  return nextStage;
1302
1388
  }
1303
1389
  toDefinition({
@@ -1332,6 +1418,47 @@ var _IndexOptimizer = class _IndexOptimizer {
1332
1418
  const colored = make(import_colorette2.green, import_colorette2.yellow, import_colorette2.magenta, import_colorette2.blue);
1333
1419
  return { raw, colored };
1334
1420
  }
1421
+ toGinDefinition({
1422
+ schema,
1423
+ table,
1424
+ column,
1425
+ opclass
1426
+ }) {
1427
+ let fullyQualifiedTable;
1428
+ if (schema.toString() === "public") {
1429
+ fullyQualifiedTable = table;
1430
+ } else {
1431
+ fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1432
+ }
1433
+ const opclassSuffix = opclass ? ` ${opclass}` : "";
1434
+ return `${fullyQualifiedTable} using gin (${column}${opclassSuffix})`;
1435
+ }
1436
+ groupGinCandidatesByColumn(candidates) {
1437
+ const groups = /* @__PURE__ */ new Map();
1438
+ for (const c of candidates) {
1439
+ if (!c.jsonbOperator) continue;
1440
+ const key = `${c.schema}.${c.table}.${c.column}`;
1441
+ const existing = groups.get(key);
1442
+ if (existing) {
1443
+ if (!existing.operators.includes(c.jsonbOperator)) {
1444
+ existing.operators.push(c.jsonbOperator);
1445
+ }
1446
+ } else {
1447
+ groups.set(key, {
1448
+ schema: c.schema,
1449
+ table: c.table,
1450
+ column: c.column,
1451
+ operators: [c.jsonbOperator]
1452
+ });
1453
+ }
1454
+ }
1455
+ return groups;
1456
+ }
1457
+ ginIndexAlreadyExists(table, column) {
1458
+ return this.existingIndexes.find(
1459
+ (index) => index.index_type === "gin" && index.table_name === table && index.index_columns.some((c) => c.name === column)
1460
+ );
1461
+ }
1335
1462
  /**
1336
1463
  * Drop indexes that can be dropped. Ignore the ones that can't
1337
1464
  */
@@ -1648,6 +1775,21 @@ var _Statistics = class _Statistics {
1648
1775
  }
1649
1776
  return "text";
1650
1777
  }
1778
+ /**
1779
+ * PostgreSQL's anyarray columns in pg_statistic can hold arrays of arrays
1780
+ * for columns with array types (e.g. text[], int4[]). These create
1781
+ * multidimensional arrays that can be "ragged" (sub-arrays with different
1782
+ * lengths). jsonb_to_recordset can't reconstruct ragged multidimensional
1783
+ * arrays from JSON, so we need to drop these values.
1784
+ */
1785
+ static safeStavalues(values) {
1786
+ if (!values || values.length === 0) return values;
1787
+ if (values.some((v) => Array.isArray(v))) {
1788
+ console.warn("Discarding ragged multidimensional stavalues array");
1789
+ return null;
1790
+ }
1791
+ return values;
1792
+ }
1651
1793
  async restoreStats17(tx) {
1652
1794
  const warnings = {
1653
1795
  tablesNotInExports: [],
@@ -1699,16 +1841,26 @@ var _Statistics = class _Statistics {
1699
1841
  stanumbers3: stats.stanumbers3,
1700
1842
  stanumbers4: stats.stanumbers4,
1701
1843
  stanumbers5: stats.stanumbers5,
1702
- stavalues1: stats.stavalues1,
1703
- stavalues2: stats.stavalues2,
1704
- stavalues3: stats.stavalues3,
1705
- stavalues4: stats.stavalues4,
1706
- stavalues5: stats.stavalues5,
1707
- _value_type1: this.stavalueKind(stats.stavalues1),
1708
- _value_type2: this.stavalueKind(stats.stavalues2),
1709
- _value_type3: this.stavalueKind(stats.stavalues3),
1710
- _value_type4: this.stavalueKind(stats.stavalues4),
1711
- _value_type5: this.stavalueKind(stats.stavalues5)
1844
+ stavalues1: _Statistics.safeStavalues(stats.stavalues1),
1845
+ stavalues2: _Statistics.safeStavalues(stats.stavalues2),
1846
+ stavalues3: _Statistics.safeStavalues(stats.stavalues3),
1847
+ stavalues4: _Statistics.safeStavalues(stats.stavalues4),
1848
+ stavalues5: _Statistics.safeStavalues(stats.stavalues5),
1849
+ _value_type1: this.stavalueKind(
1850
+ _Statistics.safeStavalues(stats.stavalues1)
1851
+ ),
1852
+ _value_type2: this.stavalueKind(
1853
+ _Statistics.safeStavalues(stats.stavalues2)
1854
+ ),
1855
+ _value_type3: this.stavalueKind(
1856
+ _Statistics.safeStavalues(stats.stavalues3)
1857
+ ),
1858
+ _value_type4: this.stavalueKind(
1859
+ _Statistics.safeStavalues(stats.stavalues4)
1860
+ ),
1861
+ _value_type5: this.stavalueKind(
1862
+ _Statistics.safeStavalues(stats.stavalues5)
1863
+ )
1712
1864
  });
1713
1865
  }
1714
1866
  }
@@ -2124,14 +2276,16 @@ var _Statistics = class _Statistics {
2124
2276
  CASE
2125
2277
  WHEN (indoption[array_position(ix.indkey, a.attnum)] & 1) = 1 THEN 'DESC'
2126
2278
  ELSE 'ASC'
2127
- END)
2279
+ END,
2280
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2128
2281
  -- Handle expressions
2129
2282
  ELSE
2130
2283
  json_build_object('name', pg_get_expr((ix.indexprs)::pg_node_tree, t.oid), 'order',
2131
2284
  CASE
2132
2285
  WHEN (indoption[array_position(ix.indkey, k.attnum)] & 1) = 1 THEN 'DESC'
2133
2286
  ELSE 'ASC'
2134
- END)
2287
+ END,
2288
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2135
2289
  END
2136
2290
  ORDER BY array_position(ix.indkey, k.attnum)
2137
2291
  ) AS index_columns
@@ -2143,6 +2297,7 @@ var _Statistics = class _Statistics {
2143
2297
  JOIN pg_am am ON i.relam = am.oid
2144
2298
  LEFT JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY k(attnum, ordinality) ON true
2145
2299
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
2300
+ LEFT JOIN pg_opclass opc ON opc.oid = ix.indclass[k.ordinality - 1]
2146
2301
  JOIN pg_namespace n ON t.relnamespace = n.oid
2147
2302
  WHERE
2148
2303
  n.nspname not like 'pg_%' and