@query-doctor/core 0.0.3 → 0.0.5

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 +226 -98
  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 +222 -98
  6. package/dist/index.js.map +1 -1
  7. package/dist/optimizer/genalgo.d.ts +10 -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 +4 -4
  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
package/dist/index.cjs CHANGED
@@ -41,13 +41,17 @@ __export(index_exports, {
41
41
  ExportedStatsV1: () => ExportedStatsV1,
42
42
  IndexOptimizer: () => IndexOptimizer,
43
43
  PROCEED: () => PROCEED,
44
+ PgIdentifier: () => PgIdentifier,
44
45
  PostgresQueryBuilder: () => PostgresQueryBuilder,
45
46
  PostgresVersion: () => PostgresVersion,
46
47
  SKIP: () => SKIP,
47
48
  Statistics: () => Statistics,
48
49
  StatisticsMode: () => StatisticsMode,
49
50
  StatisticsSource: () => StatisticsSource,
51
+ dropIndex: () => dropIndex,
50
52
  ignoredIdentifier: () => ignoredIdentifier,
53
+ isIndexProbablyDroppable: () => isIndexProbablyDroppable,
54
+ isIndexSupported: () => isIndexSupported,
51
55
  parseNudges: () => parseNudges,
52
56
  permuteWithFeedback: () => permuteWithFeedback
53
57
  });
@@ -749,9 +753,26 @@ var Analyzer = class {
749
753
  // src/sql/database.ts
750
754
  var import_zod = require("zod");
751
755
  var PostgresVersion = import_zod.z.string().brand("PostgresVersion");
756
+ async function dropIndex(tx, index) {
757
+ try {
758
+ await tx.exec(`
759
+ savepoint idx_drop;
760
+ drop index if exists ${index} cascade;
761
+ `);
762
+ return true;
763
+ } catch (error) {
764
+ await tx.exec(`rollback to idx_drop`);
765
+ return false;
766
+ }
767
+ }
752
768
 
753
- // src/optimizer/genalgo.ts
754
- var import_colorette2 = require("colorette");
769
+ // src/sql/indexes.ts
770
+ function isIndexSupported(index) {
771
+ return index.index_type === "btree";
772
+ }
773
+ function isIndexProbablyDroppable(index) {
774
+ return !index.is_primary && !index.is_unique;
775
+ }
755
776
 
756
777
  // src/sql/builder.ts
757
778
  var PostgresQueryBuilder = class _PostgresQueryBuilder {
@@ -833,7 +854,141 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
833
854
  }
834
855
  };
835
856
 
857
+ // src/sql/pg-identifier.ts
858
+ var _PgIdentifier = class _PgIdentifier {
859
+ constructor(value, quoted) {
860
+ this.value = value;
861
+ this.quoted = quoted;
862
+ }
863
+ static fromString(identifier) {
864
+ const identifierRegex = /^[a-z_][a-zA-Z0-9_]*$/;
865
+ const match = identifier.match(/^"(.+)"$/);
866
+ if (match) {
867
+ return new _PgIdentifier(match[1], true);
868
+ }
869
+ const quoted = !identifierRegex.test(identifier) || this.reservedKeywords.has(identifier.toLowerCase());
870
+ return new _PgIdentifier(identifier, quoted);
871
+ }
872
+ toString() {
873
+ if (this.quoted) {
874
+ return `"${this.value.replace(/"/g, '""')}"`;
875
+ }
876
+ return this.value;
877
+ }
878
+ };
879
+ __publicField(_PgIdentifier, "reservedKeywords", /* @__PURE__ */ new Set([
880
+ "abort",
881
+ "analyze",
882
+ "binary",
883
+ "cluster",
884
+ "constraint",
885
+ "copy",
886
+ "do",
887
+ "explain",
888
+ "extend",
889
+ "listen",
890
+ "load",
891
+ "lock",
892
+ "move",
893
+ "new",
894
+ "none",
895
+ "notify",
896
+ "offset",
897
+ "reset",
898
+ "setof",
899
+ "show",
900
+ "unlisten",
901
+ "until",
902
+ "vacuum",
903
+ "verbose",
904
+ "all",
905
+ "any",
906
+ "asc",
907
+ "between",
908
+ "bit",
909
+ "both",
910
+ "case",
911
+ "cast",
912
+ "char",
913
+ "character",
914
+ "check",
915
+ "coalesce",
916
+ "collate",
917
+ "column",
918
+ "constraint",
919
+ "cross",
920
+ "current",
921
+ "current_date",
922
+ "current_time",
923
+ "current_timestamp",
924
+ "current_user",
925
+ "dec",
926
+ "decimal",
927
+ "default",
928
+ "desc",
929
+ "distinct",
930
+ "else",
931
+ "end",
932
+ "except",
933
+ "exists",
934
+ "extract",
935
+ "false",
936
+ "float",
937
+ "for",
938
+ "foreign",
939
+ "from",
940
+ "full",
941
+ "global",
942
+ "group",
943
+ "having",
944
+ "in",
945
+ "inner",
946
+ "intersect",
947
+ "into",
948
+ "is",
949
+ "join",
950
+ "leading",
951
+ "left",
952
+ "like",
953
+ "local",
954
+ "natural",
955
+ "nchar",
956
+ "not",
957
+ "null",
958
+ "nullif",
959
+ "numeric",
960
+ "on",
961
+ "or",
962
+ "order",
963
+ "outer",
964
+ "overlaps",
965
+ "position",
966
+ "precision",
967
+ "primary",
968
+ "public",
969
+ "references",
970
+ "right",
971
+ "select",
972
+ "session_user",
973
+ "some",
974
+ "substring",
975
+ "table",
976
+ "then",
977
+ "to",
978
+ "transaction",
979
+ "trim",
980
+ "true",
981
+ "union",
982
+ "unique",
983
+ "user",
984
+ "varchar",
985
+ "when",
986
+ "where"
987
+ ]));
988
+ var PgIdentifier = _PgIdentifier;
989
+
836
990
  // src/optimizer/genalgo.ts
991
+ var import_colorette2 = require("colorette");
837
992
  var _IndexOptimizer = class _IndexOptimizer {
838
993
  constructor(db, statistics, existingIndexes, config = {}) {
839
994
  this.db = db;
@@ -841,8 +996,12 @@ var _IndexOptimizer = class _IndexOptimizer {
841
996
  this.existingIndexes = existingIndexes;
842
997
  this.config = config;
843
998
  }
844
- async run(builder, indexes) {
845
- const baseExplain = await this.runWithoutIndexes(builder);
999
+ async run(builder, indexes, beforeQuery) {
1000
+ const baseExplain = await this.testQueryWithStats(builder, async (tx) => {
1001
+ if (beforeQuery) {
1002
+ await beforeQuery(tx);
1003
+ }
1004
+ });
846
1005
  const baseCost = Number(baseExplain.Plan["Total Cost"]);
847
1006
  if (baseCost === 0) {
848
1007
  return {
@@ -850,76 +1009,17 @@ var _IndexOptimizer = class _IndexOptimizer {
850
1009
  explainPlan: baseExplain
851
1010
  };
852
1011
  }
853
- console.log("Base cost with current indexes", baseCost);
854
- const permutedIndexes = this.tableColumnIndexCandidates(indexes);
855
- const nextStage = [];
856
- const triedIndexes = /* @__PURE__ */ new Map();
857
- for (const { table, schema, columns } of permutedIndexes.values()) {
858
- const permutations = permuteWithFeedback(columns);
859
- let iter = permutations.next(PROCEED);
860
- const previousCost = baseCost;
861
- while (!iter.done) {
862
- const columns2 = iter.value;
863
- const existingIndex = this.indexAlreadyExists(table, columns2);
864
- if (existingIndex) {
865
- console.log(` <${(0, import_colorette2.gray)("skip")}> ${(0, import_colorette2.gray)(existingIndex.index_name)}`);
866
- iter = permutations.next(PROCEED);
867
- continue;
868
- }
869
- let indexDefinition = "?";
870
- const indexName = this.indexName();
871
- const { raw, colored } = this.toDefinition({
872
- columns: columns2,
873
- schema,
874
- table
875
- });
876
- const shortenedSchema = schema === "public" ? "" : `"${schema}".`;
877
- const indexDefinitionClean = `${shortenedSchema}"${table}"(${columns2.map((c) => `"${c.column}"`).join(", ")})`;
878
- indexDefinition = colored;
879
- const query = PostgresQueryBuilder.createIndex(
880
- raw,
881
- indexName
882
- ).introspect();
883
- triedIndexes.set(indexName, {
884
- schema,
885
- table,
886
- columns: columns2,
887
- definition: indexDefinitionClean
888
- });
889
- const explain = await this.testQueryWithStats(builder, async (sql) => {
890
- await sql.exec(query.build());
891
- });
892
- const explainCost = Number(explain.Plan["Total Cost"]);
893
- const costDeltaPercentage = (previousCost - explainCost) / previousCost * 100;
894
- if (previousCost > explainCost) {
895
- console.log(
896
- `${(0, import_colorette2.green)(
897
- `+${costDeltaPercentage.toFixed(2).padStart(5, "0")}%`
898
- )} ${indexDefinition} `
899
- );
900
- iter = permutations.next(PROCEED);
901
- } else {
902
- console.log(
903
- `${previousCost === explainCost ? ` ${(0, import_colorette2.gray)("00.00%")}` : `${(0, import_colorette2.red)(
904
- `-${Math.abs(costDeltaPercentage).toFixed(2).padStart(5, "0")}%`
905
- )}`} ${indexDefinition}`
906
- );
907
- iter = permutations.next(PROCEED);
908
- }
909
- nextStage.push({
910
- name: indexName,
911
- schema,
912
- table,
913
- columns: columns2
914
- });
1012
+ const toCreate = this.indexesToCreate(indexes);
1013
+ const finalExplain = await this.testQueryWithStats(builder, async (tx) => {
1014
+ if (beforeQuery) {
1015
+ await beforeQuery(tx);
915
1016
  }
916
- }
917
- const finalExplain = await this.testQueryWithStats(builder, async (sql) => {
918
- for (const permutation of nextStage) {
919
- const indexName = permutation.name;
920
- await sql.exec(
921
- `create index "${indexName}" on ${this.toDefinition(permutation).raw}; -- @qd_introspection`
922
- );
1017
+ for (const permutation of toCreate) {
1018
+ const createIndex = PostgresQueryBuilder.createIndex(
1019
+ this.toDefinition(permutation).raw,
1020
+ permutation.name
1021
+ ).introspect().build();
1022
+ await tx.exec(createIndex);
923
1023
  }
924
1024
  });
925
1025
  const finalCost = Number(finalExplain.Plan["Total Cost"]);
@@ -938,14 +1038,15 @@ var _IndexOptimizer = class _IndexOptimizer {
938
1038
  )} ${(0, import_colorette2.gray)("If there's a better index, we haven't tried it")}`
939
1039
  );
940
1040
  }
941
- const { newIndexes, existingIndexes: existingIndexesUsedByQuery } = this.findUsedIndexes(finalExplain.Plan);
1041
+ const baseIndexes = this.findUsedIndexes(baseExplain.Plan);
1042
+ const finalIndexes = this.findUsedIndexes(finalExplain.Plan);
942
1043
  return {
943
1044
  kind: "ok",
944
1045
  baseCost,
945
1046
  finalCost,
946
- newIndexes,
947
- existingIndexes: existingIndexesUsedByQuery,
948
- triedIndexes,
1047
+ newIndexes: finalIndexes.newIndexes,
1048
+ existingIndexes: baseIndexes.existingIndexes,
1049
+ triedIndexes: new Map(toCreate.map((index) => [index.name, index])),
949
1050
  baseExplainPlan: baseExplain,
950
1051
  explainPlan: finalExplain
951
1052
  };
@@ -969,6 +1070,37 @@ var _IndexOptimizer = class _IndexOptimizer {
969
1070
  (index) => index.index_type === "btree" && index.table_name === table && index.index_columns.length === columns.length && index.index_columns.every((c, i) => columns[i].column === c.name)
970
1071
  );
971
1072
  }
1073
+ /**
1074
+ * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1075
+ **/
1076
+ indexesToCreate(rootCandidates) {
1077
+ const permutedIndexes = this.tableColumnIndexCandidates(rootCandidates);
1078
+ const nextStage = [];
1079
+ for (const { table, schema, columns } of permutedIndexes.values()) {
1080
+ const permutations = permuteWithFeedback(columns);
1081
+ let iter = permutations.next(PROCEED);
1082
+ while (!iter.done) {
1083
+ const columns2 = iter.value;
1084
+ const existingIndex = this.indexAlreadyExists(table, columns2);
1085
+ if (existingIndex) {
1086
+ iter = permutations.next(PROCEED);
1087
+ continue;
1088
+ }
1089
+ const indexName = this.indexName();
1090
+ const shortenedSchema = schema === "public" ? "" : `"${schema}".`;
1091
+ const indexDefinitionClean = `${shortenedSchema}"${table}"(${columns2.map((c) => `"${c.column}"`).join(", ")})`;
1092
+ iter = permutations.next(PROCEED);
1093
+ nextStage.push({
1094
+ name: indexName,
1095
+ schema,
1096
+ table,
1097
+ columns: columns2,
1098
+ definition: indexDefinitionClean
1099
+ });
1100
+ }
1101
+ }
1102
+ return nextStage;
1103
+ }
972
1104
  toDefinition(permuted) {
973
1105
  const make = (col, order, where, keyword) => {
974
1106
  const baseColumn = `"${permuted.schema}"."${permuted.table}"(${permuted.columns.map((c) => {
@@ -991,16 +1123,15 @@ var _IndexOptimizer = class _IndexOptimizer {
991
1123
  return { raw, colored };
992
1124
  }
993
1125
  /**
994
- * Drop indexes that can be dropped (non-primary keys)
1126
+ * Drop indexes that can be dropped. Ignore the ones that can't
995
1127
  */
996
1128
  async dropExistingIndexes(tx) {
997
1129
  for (const index of this.existingIndexes) {
998
- if (index.is_primary) {
1130
+ if (!isIndexProbablyDroppable(index)) {
999
1131
  continue;
1000
1132
  }
1001
- await tx.exec(
1002
- `drop index if exists ${index.schema_name}.${index.index_name} cascade`
1003
- );
1133
+ const indexName = `${index.schema_name}.${index.index_name}`;
1134
+ await dropIndex(tx, indexName);
1004
1135
  }
1005
1136
  }
1006
1137
  whereClause(c, col, keyword) {
@@ -1050,9 +1181,12 @@ var _IndexOptimizer = class _IndexOptimizer {
1050
1181
  await this.db.transaction(async (tx) => {
1051
1182
  await f?.(tx);
1052
1183
  await this.statistics.restoreStats(tx);
1053
- const flags = ["format json", "trace"];
1184
+ const flags = ["format json"];
1054
1185
  if (options && !options.genericPlan) {
1055
1186
  flags.push("analyze");
1187
+ if (this.config.trace) {
1188
+ flags.push("trace");
1189
+ }
1056
1190
  } else {
1057
1191
  flags.push("generic_plan");
1058
1192
  }
@@ -1189,13 +1323,7 @@ var ExportedStatsStatistics = import_zod2.z.object({
1189
1323
  });
1190
1324
  var ExportedStatsColumns = import_zod2.z.object({
1191
1325
  columnName: import_zod2.z.string(),
1192
- stats: ExportedStatsStatistics.nullable(),
1193
- dataType: import_zod2.z.string(),
1194
- isNullable: import_zod2.z.boolean(),
1195
- numericScale: import_zod2.z.number().nullable(),
1196
- columnDefault: import_zod2.z.string().nullable(),
1197
- numericPrecision: import_zod2.z.number().nullable(),
1198
- characterMaximumLength: import_zod2.z.number().nullable()
1326
+ stats: ExportedStatsStatistics.nullable()
1199
1327
  });
1200
1328
  var ExportedStatsIndex = import_zod2.z.object({
1201
1329
  indexName: import_zod2.z.string(),
@@ -1663,12 +1791,6 @@ var _Statistics = class _Statistics {
1663
1791
  json_agg(
1664
1792
  json_build_object(
1665
1793
  'columnName', c.column_name,
1666
- 'dataType', c.data_type,
1667
- 'isNullable', (c.is_nullable = 'YES')::boolean,
1668
- 'characterMaximumLength', c.character_maximum_length,
1669
- 'numericPrecision', c.numeric_precision,
1670
- 'numericScale', c.numeric_scale,
1671
- 'columnDefault', c.column_default,
1672
1794
  'stats', (
1673
1795
  SELECT json_build_object(
1674
1796
  'starelid', s.starelid,
@@ -1764,6 +1886,7 @@ var _Statistics = class _Statistics {
1764
1886
  COALESCE(pt.parent_table::text, t.relname) AS table_name,
1765
1887
  i.relname AS index_name,
1766
1888
  ix.indisprimary as is_primary,
1889
+ ix.indisunique as is_unique,
1767
1890
  am.amname AS index_type,
1768
1891
  array_agg(
1769
1892
  CASE
@@ -1794,9 +1917,10 @@ var _Statistics = class _Statistics {
1794
1917
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
1795
1918
  JOIN pg_namespace n ON t.relnamespace = n.oid
1796
1919
  WHERE
1797
- n.nspname = 'public'
1920
+ n.nspname not like 'pg_%' and
1921
+ n.nspname <> 'information_schema'
1798
1922
  GROUP BY
1799
- n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary
1923
+ n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary, ix.indisunique
1800
1924
  ORDER BY
1801
1925
  COALESCE(pt.parent_table::text, t.relname), i.relname; -- @qd_introspection
1802
1926
  `);
@@ -1820,13 +1944,17 @@ var Statistics = _Statistics;
1820
1944
  ExportedStatsV1,
1821
1945
  IndexOptimizer,
1822
1946
  PROCEED,
1947
+ PgIdentifier,
1823
1948
  PostgresQueryBuilder,
1824
1949
  PostgresVersion,
1825
1950
  SKIP,
1826
1951
  Statistics,
1827
1952
  StatisticsMode,
1828
1953
  StatisticsSource,
1954
+ dropIndex,
1829
1955
  ignoredIdentifier,
1956
+ isIndexProbablyDroppable,
1957
+ isIndexSupported,
1830
1958
  parseNudges,
1831
1959
  permuteWithFeedback
1832
1960
  });