@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.js CHANGED
@@ -343,6 +343,18 @@ var Walker = class _Walker {
343
343
  }
344
344
  }
345
345
  }
346
+ if (is2(node, "A_Expr") && node.A_Expr.kind === "AEXPR_OP") {
347
+ const opName = node.A_Expr.name?.[0] && is2(node.A_Expr.name[0], "String") && node.A_Expr.name[0].String.sval;
348
+ if (opName && (opName === "@>" || opName === "?" || opName === "?|" || opName === "?&")) {
349
+ const jsonbOperator = opName;
350
+ if (node.A_Expr.lexpr && is2(node.A_Expr.lexpr, "ColumnRef")) {
351
+ this.add(node.A_Expr.lexpr, { jsonbOperator });
352
+ }
353
+ if (node.A_Expr.rexpr && is2(node.A_Expr.rexpr, "ColumnRef")) {
354
+ this.add(node.A_Expr.rexpr, { jsonbOperator });
355
+ }
356
+ }
357
+ }
346
358
  if (is2(node, "ColumnRef")) {
347
359
  for (let i = 0; i < stack.length; i++) {
348
360
  const inReturningList = stack[i] === "returningList" && stack[i + 1] === "ResTarget" && stack[i + 2] === "val" && stack[i + 3] === "ColumnRef";
@@ -441,6 +453,9 @@ var Walker = class _Walker {
441
453
  if (options?.where) {
442
454
  ref.where = options.where;
443
455
  }
456
+ if (options?.jsonbOperator) {
457
+ ref.jsonbOperator = options.jsonbOperator;
458
+ }
444
459
  this.highlights.push(ref);
445
460
  }
446
461
  static traverse(node, stack, callback) {
@@ -605,6 +620,9 @@ var Analyzer = class {
605
620
  if (colReference.where) {
606
621
  index.where = colReference.where;
607
622
  }
623
+ if (colReference.jsonbOperator) {
624
+ index.jsonbOperator = colReference.jsonbOperator;
625
+ }
608
626
  addIndex(index);
609
627
  }
610
628
  } else if (tableReference) {
@@ -626,6 +644,9 @@ var Analyzer = class {
626
644
  if (colReference.where) {
627
645
  index.where = colReference.where;
628
646
  }
647
+ if (colReference.jsonbOperator) {
648
+ index.jsonbOperator = colReference.jsonbOperator;
649
+ }
629
650
  addIndex(index);
630
651
  }
631
652
  } else if (fullReference) {
@@ -644,6 +665,9 @@ var Analyzer = class {
644
665
  if (colReference.where) {
645
666
  index.where = colReference.where;
646
667
  }
668
+ if (colReference.jsonbOperator) {
669
+ index.jsonbOperator = colReference.jsonbOperator;
670
+ }
647
671
  addIndex(index);
648
672
  } else {
649
673
  console.error(
@@ -766,7 +790,7 @@ async function dropIndex(tx, index) {
766
790
 
767
791
  // src/sql/indexes.ts
768
792
  function isIndexSupported(index) {
769
- return index.index_type === "btree";
793
+ return index.index_type === "btree" || index.index_type === "gin";
770
794
  }
771
795
  function isIndexProbablyDroppable(index) {
772
796
  return !index.is_primary && !index.is_unique;
@@ -780,6 +804,9 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
780
804
  __publicField(this, "isIntrospection", false);
781
805
  __publicField(this, "explainFlags", []);
782
806
  __publicField(this, "_preamble", 0);
807
+ __publicField(this, "parameters", {});
808
+ // substitution for `limit $1` -> `limit 5`
809
+ __publicField(this, "limitSubstitution");
783
810
  }
784
811
  get preamble() {
785
812
  return this._preamble;
@@ -813,6 +840,14 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
813
840
  this.explainFlags = flags;
814
841
  return this;
815
842
  }
843
+ parameterize(parameters) {
844
+ Object.assign(this.parameters, parameters);
845
+ return this;
846
+ }
847
+ replaceLimit(limit) {
848
+ this.limitSubstitution = limit;
849
+ return this;
850
+ }
816
851
  build() {
817
852
  let commands = this.generateSetCommands();
818
853
  commands += this.generateExplain().query;
@@ -841,14 +876,28 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
841
876
  return commands;
842
877
  }
843
878
  generateExplain() {
844
- let query = "";
879
+ let finalQuery = "";
845
880
  if (this.explainFlags.length > 0) {
846
- query += `explain (${this.explainFlags.join(", ")}) `;
881
+ finalQuery += `explain (${this.explainFlags.join(", ")}) `;
882
+ }
883
+ const query = this.substituteQuery();
884
+ const semicolon = query.endsWith(";") ? "" : ";";
885
+ const preamble = finalQuery.length;
886
+ finalQuery += `${query}${semicolon}`;
887
+ return { query: finalQuery, preamble };
888
+ }
889
+ substituteQuery() {
890
+ let query = this.query;
891
+ if (this.limitSubstitution !== void 0) {
892
+ query = query.replace(
893
+ /limit\s+\$\d+/g,
894
+ `limit ${this.limitSubstitution}`
895
+ );
896
+ }
897
+ for (const [key, value] of Object.entries(this.parameters)) {
898
+ query = query.replaceAll(`\\$${key}`, value.toString());
847
899
  }
848
- const semicolon = this.query.endsWith(";") ? "" : ";";
849
- const preamble = query.length;
850
- query += `${this.query}${semicolon}`;
851
- return { query, preamble };
900
+ return query;
852
901
  }
853
902
  };
854
903
 
@@ -1223,8 +1272,10 @@ var _IndexOptimizer = class _IndexOptimizer {
1223
1272
  * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1224
1273
  **/
1225
1274
  indexesToCreate(rootCandidates) {
1226
- const permutedIndexes = this.groupPotentialIndexColumnsByTable(rootCandidates);
1275
+ const btreeCandidates = rootCandidates.filter((c) => !c.jsonbOperator);
1276
+ const ginCandidates = rootCandidates.filter((c) => c.jsonbOperator);
1227
1277
  const nextStage = [];
1278
+ const permutedIndexes = this.groupPotentialIndexColumnsByTable(btreeCandidates);
1228
1279
  for (const permutation of permutedIndexes.values()) {
1229
1280
  const { table: rawTable, schema: rawSchema, columns } = permutation;
1230
1281
  const permutations = permutationsWithDescendingLength(columns);
@@ -1249,6 +1300,41 @@ var _IndexOptimizer = class _IndexOptimizer {
1249
1300
  });
1250
1301
  }
1251
1302
  }
1303
+ const ginGroups = this.groupGinCandidatesByColumn(ginCandidates);
1304
+ for (const group of ginGroups.values()) {
1305
+ const { schema: rawSchema, table: rawTable, column, operators } = group;
1306
+ const schema = PgIdentifier.fromString(rawSchema);
1307
+ const table = PgIdentifier.fromString(rawTable);
1308
+ const needsKeyExistence = operators.some(
1309
+ (op) => op === "?" || op === "?|" || op === "?&"
1310
+ );
1311
+ const opclass = needsKeyExistence ? void 0 : "jsonb_path_ops";
1312
+ const existingGin = this.ginIndexAlreadyExists(table.toString(), column);
1313
+ if (existingGin) {
1314
+ continue;
1315
+ }
1316
+ const indexName = this.indexName();
1317
+ const candidate = {
1318
+ schema: rawSchema,
1319
+ table: rawTable,
1320
+ column
1321
+ };
1322
+ const definition = this.toGinDefinition({
1323
+ table,
1324
+ schema,
1325
+ column: PgIdentifier.fromString(column),
1326
+ opclass
1327
+ });
1328
+ nextStage.push({
1329
+ name: indexName,
1330
+ schema: schema.toString(),
1331
+ table: table.toString(),
1332
+ columns: [candidate],
1333
+ definition,
1334
+ indexMethod: "gin",
1335
+ opclass
1336
+ });
1337
+ }
1252
1338
  return nextStage;
1253
1339
  }
1254
1340
  toDefinition({
@@ -1283,6 +1369,47 @@ var _IndexOptimizer = class _IndexOptimizer {
1283
1369
  const colored = make(green, yellow, magenta, blue2);
1284
1370
  return { raw, colored };
1285
1371
  }
1372
+ toGinDefinition({
1373
+ schema,
1374
+ table,
1375
+ column,
1376
+ opclass
1377
+ }) {
1378
+ let fullyQualifiedTable;
1379
+ if (schema.toString() === "public") {
1380
+ fullyQualifiedTable = table;
1381
+ } else {
1382
+ fullyQualifiedTable = PgIdentifier.fromParts(schema, table);
1383
+ }
1384
+ const opclassSuffix = opclass ? ` ${opclass}` : "";
1385
+ return `${fullyQualifiedTable} using gin (${column}${opclassSuffix})`;
1386
+ }
1387
+ groupGinCandidatesByColumn(candidates) {
1388
+ const groups = /* @__PURE__ */ new Map();
1389
+ for (const c of candidates) {
1390
+ if (!c.jsonbOperator) continue;
1391
+ const key = `${c.schema}.${c.table}.${c.column}`;
1392
+ const existing = groups.get(key);
1393
+ if (existing) {
1394
+ if (!existing.operators.includes(c.jsonbOperator)) {
1395
+ existing.operators.push(c.jsonbOperator);
1396
+ }
1397
+ } else {
1398
+ groups.set(key, {
1399
+ schema: c.schema,
1400
+ table: c.table,
1401
+ column: c.column,
1402
+ operators: [c.jsonbOperator]
1403
+ });
1404
+ }
1405
+ }
1406
+ return groups;
1407
+ }
1408
+ ginIndexAlreadyExists(table, column) {
1409
+ return this.existingIndexes.find(
1410
+ (index) => index.index_type === "gin" && index.table_name === table && index.index_columns.some((c) => c.name === column)
1411
+ );
1412
+ }
1286
1413
  /**
1287
1414
  * Drop indexes that can be dropped. Ignore the ones that can't
1288
1415
  */
@@ -1599,6 +1726,21 @@ var _Statistics = class _Statistics {
1599
1726
  }
1600
1727
  return "text";
1601
1728
  }
1729
+ /**
1730
+ * PostgreSQL's anyarray columns in pg_statistic can hold arrays of arrays
1731
+ * for columns with array types (e.g. text[], int4[]). These create
1732
+ * multidimensional arrays that can be "ragged" (sub-arrays with different
1733
+ * lengths). jsonb_to_recordset can't reconstruct ragged multidimensional
1734
+ * arrays from JSON, so we need to drop these values.
1735
+ */
1736
+ static safeStavalues(values) {
1737
+ if (!values || values.length === 0) return values;
1738
+ if (values.some((v) => Array.isArray(v))) {
1739
+ console.warn("Discarding ragged multidimensional stavalues array");
1740
+ return null;
1741
+ }
1742
+ return values;
1743
+ }
1602
1744
  async restoreStats17(tx) {
1603
1745
  const warnings = {
1604
1746
  tablesNotInExports: [],
@@ -1650,16 +1792,26 @@ var _Statistics = class _Statistics {
1650
1792
  stanumbers3: stats.stanumbers3,
1651
1793
  stanumbers4: stats.stanumbers4,
1652
1794
  stanumbers5: stats.stanumbers5,
1653
- stavalues1: stats.stavalues1,
1654
- stavalues2: stats.stavalues2,
1655
- stavalues3: stats.stavalues3,
1656
- stavalues4: stats.stavalues4,
1657
- stavalues5: stats.stavalues5,
1658
- _value_type1: this.stavalueKind(stats.stavalues1),
1659
- _value_type2: this.stavalueKind(stats.stavalues2),
1660
- _value_type3: this.stavalueKind(stats.stavalues3),
1661
- _value_type4: this.stavalueKind(stats.stavalues4),
1662
- _value_type5: this.stavalueKind(stats.stavalues5)
1795
+ stavalues1: _Statistics.safeStavalues(stats.stavalues1),
1796
+ stavalues2: _Statistics.safeStavalues(stats.stavalues2),
1797
+ stavalues3: _Statistics.safeStavalues(stats.stavalues3),
1798
+ stavalues4: _Statistics.safeStavalues(stats.stavalues4),
1799
+ stavalues5: _Statistics.safeStavalues(stats.stavalues5),
1800
+ _value_type1: this.stavalueKind(
1801
+ _Statistics.safeStavalues(stats.stavalues1)
1802
+ ),
1803
+ _value_type2: this.stavalueKind(
1804
+ _Statistics.safeStavalues(stats.stavalues2)
1805
+ ),
1806
+ _value_type3: this.stavalueKind(
1807
+ _Statistics.safeStavalues(stats.stavalues3)
1808
+ ),
1809
+ _value_type4: this.stavalueKind(
1810
+ _Statistics.safeStavalues(stats.stavalues4)
1811
+ ),
1812
+ _value_type5: this.stavalueKind(
1813
+ _Statistics.safeStavalues(stats.stavalues5)
1814
+ )
1663
1815
  });
1664
1816
  }
1665
1817
  }
@@ -2075,14 +2227,16 @@ var _Statistics = class _Statistics {
2075
2227
  CASE
2076
2228
  WHEN (indoption[array_position(ix.indkey, a.attnum)] & 1) = 1 THEN 'DESC'
2077
2229
  ELSE 'ASC'
2078
- END)
2230
+ END,
2231
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2079
2232
  -- Handle expressions
2080
2233
  ELSE
2081
2234
  json_build_object('name', pg_get_expr((ix.indexprs)::pg_node_tree, t.oid), 'order',
2082
2235
  CASE
2083
2236
  WHEN (indoption[array_position(ix.indkey, k.attnum)] & 1) = 1 THEN 'DESC'
2084
2237
  ELSE 'ASC'
2085
- END)
2238
+ END,
2239
+ 'opclass', CASE WHEN opc.opcdefault THEN NULL ELSE opc.opcname END)
2086
2240
  END
2087
2241
  ORDER BY array_position(ix.indkey, k.attnum)
2088
2242
  ) AS index_columns
@@ -2094,6 +2248,7 @@ var _Statistics = class _Statistics {
2094
2248
  JOIN pg_am am ON i.relam = am.oid
2095
2249
  LEFT JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY k(attnum, ordinality) ON true
2096
2250
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
2251
+ LEFT JOIN pg_opclass opc ON opc.oid = ix.indclass[k.ordinality - 1]
2097
2252
  JOIN pg_namespace n ON t.relnamespace = n.oid
2098
2253
  WHERE
2099
2254
  n.nspname not like 'pg_%' and