@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.js CHANGED
@@ -704,9 +704,26 @@ var Analyzer = class {
704
704
  // src/sql/database.ts
705
705
  import { z } from "zod";
706
706
  var PostgresVersion = z.string().brand("PostgresVersion");
707
+ async function dropIndex(tx, index) {
708
+ try {
709
+ await tx.exec(`
710
+ savepoint idx_drop;
711
+ drop index if exists ${index} cascade;
712
+ `);
713
+ return true;
714
+ } catch (error) {
715
+ await tx.exec(`rollback to idx_drop`);
716
+ return false;
717
+ }
718
+ }
707
719
 
708
- // src/optimizer/genalgo.ts
709
- import { blue as blue2, gray, green, magenta, red, yellow } from "colorette";
720
+ // src/sql/indexes.ts
721
+ function isIndexSupported(index) {
722
+ return index.index_type === "btree";
723
+ }
724
+ function isIndexProbablyDroppable(index) {
725
+ return !index.is_primary && !index.is_unique;
726
+ }
710
727
 
711
728
  // src/sql/builder.ts
712
729
  var PostgresQueryBuilder = class _PostgresQueryBuilder {
@@ -788,7 +805,141 @@ var PostgresQueryBuilder = class _PostgresQueryBuilder {
788
805
  }
789
806
  };
790
807
 
808
+ // src/sql/pg-identifier.ts
809
+ var _PgIdentifier = class _PgIdentifier {
810
+ constructor(value, quoted) {
811
+ this.value = value;
812
+ this.quoted = quoted;
813
+ }
814
+ static fromString(identifier) {
815
+ const identifierRegex = /^[a-z_][a-zA-Z0-9_]*$/;
816
+ const match = identifier.match(/^"(.+)"$/);
817
+ if (match) {
818
+ return new _PgIdentifier(match[1], true);
819
+ }
820
+ const quoted = !identifierRegex.test(identifier) || this.reservedKeywords.has(identifier.toLowerCase());
821
+ return new _PgIdentifier(identifier, quoted);
822
+ }
823
+ toString() {
824
+ if (this.quoted) {
825
+ return `"${this.value.replace(/"/g, '""')}"`;
826
+ }
827
+ return this.value;
828
+ }
829
+ };
830
+ __publicField(_PgIdentifier, "reservedKeywords", /* @__PURE__ */ new Set([
831
+ "abort",
832
+ "analyze",
833
+ "binary",
834
+ "cluster",
835
+ "constraint",
836
+ "copy",
837
+ "do",
838
+ "explain",
839
+ "extend",
840
+ "listen",
841
+ "load",
842
+ "lock",
843
+ "move",
844
+ "new",
845
+ "none",
846
+ "notify",
847
+ "offset",
848
+ "reset",
849
+ "setof",
850
+ "show",
851
+ "unlisten",
852
+ "until",
853
+ "vacuum",
854
+ "verbose",
855
+ "all",
856
+ "any",
857
+ "asc",
858
+ "between",
859
+ "bit",
860
+ "both",
861
+ "case",
862
+ "cast",
863
+ "char",
864
+ "character",
865
+ "check",
866
+ "coalesce",
867
+ "collate",
868
+ "column",
869
+ "constraint",
870
+ "cross",
871
+ "current",
872
+ "current_date",
873
+ "current_time",
874
+ "current_timestamp",
875
+ "current_user",
876
+ "dec",
877
+ "decimal",
878
+ "default",
879
+ "desc",
880
+ "distinct",
881
+ "else",
882
+ "end",
883
+ "except",
884
+ "exists",
885
+ "extract",
886
+ "false",
887
+ "float",
888
+ "for",
889
+ "foreign",
890
+ "from",
891
+ "full",
892
+ "global",
893
+ "group",
894
+ "having",
895
+ "in",
896
+ "inner",
897
+ "intersect",
898
+ "into",
899
+ "is",
900
+ "join",
901
+ "leading",
902
+ "left",
903
+ "like",
904
+ "local",
905
+ "natural",
906
+ "nchar",
907
+ "not",
908
+ "null",
909
+ "nullif",
910
+ "numeric",
911
+ "on",
912
+ "or",
913
+ "order",
914
+ "outer",
915
+ "overlaps",
916
+ "position",
917
+ "precision",
918
+ "primary",
919
+ "public",
920
+ "references",
921
+ "right",
922
+ "select",
923
+ "session_user",
924
+ "some",
925
+ "substring",
926
+ "table",
927
+ "then",
928
+ "to",
929
+ "transaction",
930
+ "trim",
931
+ "true",
932
+ "union",
933
+ "unique",
934
+ "user",
935
+ "varchar",
936
+ "when",
937
+ "where"
938
+ ]));
939
+ var PgIdentifier = _PgIdentifier;
940
+
791
941
  // src/optimizer/genalgo.ts
942
+ import { blue as blue2, gray, green, magenta, red, yellow } from "colorette";
792
943
  var _IndexOptimizer = class _IndexOptimizer {
793
944
  constructor(db, statistics, existingIndexes, config = {}) {
794
945
  this.db = db;
@@ -796,8 +947,12 @@ var _IndexOptimizer = class _IndexOptimizer {
796
947
  this.existingIndexes = existingIndexes;
797
948
  this.config = config;
798
949
  }
799
- async run(builder, indexes) {
800
- const baseExplain = await this.runWithoutIndexes(builder);
950
+ async run(builder, indexes, beforeQuery) {
951
+ const baseExplain = await this.testQueryWithStats(builder, async (tx) => {
952
+ if (beforeQuery) {
953
+ await beforeQuery(tx);
954
+ }
955
+ });
801
956
  const baseCost = Number(baseExplain.Plan["Total Cost"]);
802
957
  if (baseCost === 0) {
803
958
  return {
@@ -805,76 +960,17 @@ var _IndexOptimizer = class _IndexOptimizer {
805
960
  explainPlan: baseExplain
806
961
  };
807
962
  }
808
- console.log("Base cost with current indexes", baseCost);
809
- const permutedIndexes = this.tableColumnIndexCandidates(indexes);
810
- const nextStage = [];
811
- const triedIndexes = /* @__PURE__ */ new Map();
812
- for (const { table, schema, columns } of permutedIndexes.values()) {
813
- const permutations = permuteWithFeedback(columns);
814
- let iter = permutations.next(PROCEED);
815
- const previousCost = baseCost;
816
- while (!iter.done) {
817
- const columns2 = iter.value;
818
- const existingIndex = this.indexAlreadyExists(table, columns2);
819
- if (existingIndex) {
820
- console.log(` <${gray("skip")}> ${gray(existingIndex.index_name)}`);
821
- iter = permutations.next(PROCEED);
822
- continue;
823
- }
824
- let indexDefinition = "?";
825
- const indexName = this.indexName();
826
- const { raw, colored } = this.toDefinition({
827
- columns: columns2,
828
- schema,
829
- table
830
- });
831
- const shortenedSchema = schema === "public" ? "" : `"${schema}".`;
832
- const indexDefinitionClean = `${shortenedSchema}"${table}"(${columns2.map((c) => `"${c.column}"`).join(", ")})`;
833
- indexDefinition = colored;
834
- const query = PostgresQueryBuilder.createIndex(
835
- raw,
836
- indexName
837
- ).introspect();
838
- triedIndexes.set(indexName, {
839
- schema,
840
- table,
841
- columns: columns2,
842
- definition: indexDefinitionClean
843
- });
844
- const explain = await this.testQueryWithStats(builder, async (sql) => {
845
- await sql.exec(query.build());
846
- });
847
- const explainCost = Number(explain.Plan["Total Cost"]);
848
- const costDeltaPercentage = (previousCost - explainCost) / previousCost * 100;
849
- if (previousCost > explainCost) {
850
- console.log(
851
- `${green(
852
- `+${costDeltaPercentage.toFixed(2).padStart(5, "0")}%`
853
- )} ${indexDefinition} `
854
- );
855
- iter = permutations.next(PROCEED);
856
- } else {
857
- console.log(
858
- `${previousCost === explainCost ? ` ${gray("00.00%")}` : `${red(
859
- `-${Math.abs(costDeltaPercentage).toFixed(2).padStart(5, "0")}%`
860
- )}`} ${indexDefinition}`
861
- );
862
- iter = permutations.next(PROCEED);
863
- }
864
- nextStage.push({
865
- name: indexName,
866
- schema,
867
- table,
868
- columns: columns2
869
- });
963
+ const toCreate = this.indexesToCreate(indexes);
964
+ const finalExplain = await this.testQueryWithStats(builder, async (tx) => {
965
+ if (beforeQuery) {
966
+ await beforeQuery(tx);
870
967
  }
871
- }
872
- const finalExplain = await this.testQueryWithStats(builder, async (sql) => {
873
- for (const permutation of nextStage) {
874
- const indexName = permutation.name;
875
- await sql.exec(
876
- `create index "${indexName}" on ${this.toDefinition(permutation).raw}; -- @qd_introspection`
877
- );
968
+ for (const permutation of toCreate) {
969
+ const createIndex = PostgresQueryBuilder.createIndex(
970
+ this.toDefinition(permutation).raw,
971
+ permutation.name
972
+ ).introspect().build();
973
+ await tx.exec(createIndex);
878
974
  }
879
975
  });
880
976
  const finalCost = Number(finalExplain.Plan["Total Cost"]);
@@ -893,14 +989,15 @@ var _IndexOptimizer = class _IndexOptimizer {
893
989
  )} ${gray("If there's a better index, we haven't tried it")}`
894
990
  );
895
991
  }
896
- const { newIndexes, existingIndexes: existingIndexesUsedByQuery } = this.findUsedIndexes(finalExplain.Plan);
992
+ const baseIndexes = this.findUsedIndexes(baseExplain.Plan);
993
+ const finalIndexes = this.findUsedIndexes(finalExplain.Plan);
897
994
  return {
898
995
  kind: "ok",
899
996
  baseCost,
900
997
  finalCost,
901
- newIndexes,
902
- existingIndexes: existingIndexesUsedByQuery,
903
- triedIndexes,
998
+ newIndexes: finalIndexes.newIndexes,
999
+ existingIndexes: baseIndexes.existingIndexes,
1000
+ triedIndexes: new Map(toCreate.map((index) => [index.name, index])),
904
1001
  baseExplainPlan: baseExplain,
905
1002
  explainPlan: finalExplain
906
1003
  };
@@ -924,6 +1021,37 @@ var _IndexOptimizer = class _IndexOptimizer {
924
1021
  (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)
925
1022
  );
926
1023
  }
1024
+ /**
1025
+ * Derive the list of indexes [tableA(X, Y, Z), tableB(H, I, J)]
1026
+ **/
1027
+ indexesToCreate(rootCandidates) {
1028
+ const permutedIndexes = this.tableColumnIndexCandidates(rootCandidates);
1029
+ const nextStage = [];
1030
+ for (const { table, schema, columns } of permutedIndexes.values()) {
1031
+ const permutations = permuteWithFeedback(columns);
1032
+ let iter = permutations.next(PROCEED);
1033
+ while (!iter.done) {
1034
+ const columns2 = iter.value;
1035
+ const existingIndex = this.indexAlreadyExists(table, columns2);
1036
+ if (existingIndex) {
1037
+ iter = permutations.next(PROCEED);
1038
+ continue;
1039
+ }
1040
+ const indexName = this.indexName();
1041
+ const shortenedSchema = schema === "public" ? "" : `"${schema}".`;
1042
+ const indexDefinitionClean = `${shortenedSchema}"${table}"(${columns2.map((c) => `"${c.column}"`).join(", ")})`;
1043
+ iter = permutations.next(PROCEED);
1044
+ nextStage.push({
1045
+ name: indexName,
1046
+ schema,
1047
+ table,
1048
+ columns: columns2,
1049
+ definition: indexDefinitionClean
1050
+ });
1051
+ }
1052
+ }
1053
+ return nextStage;
1054
+ }
927
1055
  toDefinition(permuted) {
928
1056
  const make = (col, order, where, keyword) => {
929
1057
  const baseColumn = `"${permuted.schema}"."${permuted.table}"(${permuted.columns.map((c) => {
@@ -946,16 +1074,15 @@ var _IndexOptimizer = class _IndexOptimizer {
946
1074
  return { raw, colored };
947
1075
  }
948
1076
  /**
949
- * Drop indexes that can be dropped (non-primary keys)
1077
+ * Drop indexes that can be dropped. Ignore the ones that can't
950
1078
  */
951
1079
  async dropExistingIndexes(tx) {
952
1080
  for (const index of this.existingIndexes) {
953
- if (index.is_primary) {
1081
+ if (!isIndexProbablyDroppable(index)) {
954
1082
  continue;
955
1083
  }
956
- await tx.exec(
957
- `drop index if exists ${index.schema_name}.${index.index_name} cascade`
958
- );
1084
+ const indexName = `${index.schema_name}.${index.index_name}`;
1085
+ await dropIndex(tx, indexName);
959
1086
  }
960
1087
  }
961
1088
  whereClause(c, col, keyword) {
@@ -1005,9 +1132,12 @@ var _IndexOptimizer = class _IndexOptimizer {
1005
1132
  await this.db.transaction(async (tx) => {
1006
1133
  await f?.(tx);
1007
1134
  await this.statistics.restoreStats(tx);
1008
- const flags = ["format json", "trace"];
1135
+ const flags = ["format json"];
1009
1136
  if (options && !options.genericPlan) {
1010
1137
  flags.push("analyze");
1138
+ if (this.config.trace) {
1139
+ flags.push("trace");
1140
+ }
1011
1141
  } else {
1012
1142
  flags.push("generic_plan");
1013
1143
  }
@@ -1144,13 +1274,7 @@ var ExportedStatsStatistics = z2.object({
1144
1274
  });
1145
1275
  var ExportedStatsColumns = z2.object({
1146
1276
  columnName: z2.string(),
1147
- stats: ExportedStatsStatistics.nullable(),
1148
- dataType: z2.string(),
1149
- isNullable: z2.boolean(),
1150
- numericScale: z2.number().nullable(),
1151
- columnDefault: z2.string().nullable(),
1152
- numericPrecision: z2.number().nullable(),
1153
- characterMaximumLength: z2.number().nullable()
1277
+ stats: ExportedStatsStatistics.nullable()
1154
1278
  });
1155
1279
  var ExportedStatsIndex = z2.object({
1156
1280
  indexName: z2.string(),
@@ -1618,12 +1742,6 @@ var _Statistics = class _Statistics {
1618
1742
  json_agg(
1619
1743
  json_build_object(
1620
1744
  'columnName', c.column_name,
1621
- 'dataType', c.data_type,
1622
- 'isNullable', (c.is_nullable = 'YES')::boolean,
1623
- 'characterMaximumLength', c.character_maximum_length,
1624
- 'numericPrecision', c.numeric_precision,
1625
- 'numericScale', c.numeric_scale,
1626
- 'columnDefault', c.column_default,
1627
1745
  'stats', (
1628
1746
  SELECT json_build_object(
1629
1747
  'starelid', s.starelid,
@@ -1719,6 +1837,7 @@ var _Statistics = class _Statistics {
1719
1837
  COALESCE(pt.parent_table::text, t.relname) AS table_name,
1720
1838
  i.relname AS index_name,
1721
1839
  ix.indisprimary as is_primary,
1840
+ ix.indisunique as is_unique,
1722
1841
  am.amname AS index_type,
1723
1842
  array_agg(
1724
1843
  CASE
@@ -1749,9 +1868,10 @@ var _Statistics = class _Statistics {
1749
1868
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
1750
1869
  JOIN pg_namespace n ON t.relnamespace = n.oid
1751
1870
  WHERE
1752
- n.nspname = 'public'
1871
+ n.nspname not like 'pg_%' and
1872
+ n.nspname <> 'information_schema'
1753
1873
  GROUP BY
1754
- n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary
1874
+ n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary, ix.indisunique
1755
1875
  ORDER BY
1756
1876
  COALESCE(pt.parent_table::text, t.relname), i.relname; -- @qd_introspection
1757
1877
  `);
@@ -1774,13 +1894,17 @@ export {
1774
1894
  ExportedStatsV1,
1775
1895
  IndexOptimizer,
1776
1896
  PROCEED,
1897
+ PgIdentifier,
1777
1898
  PostgresQueryBuilder,
1778
1899
  PostgresVersion,
1779
1900
  SKIP,
1780
1901
  Statistics,
1781
1902
  StatisticsMode,
1782
1903
  StatisticsSource,
1904
+ dropIndex,
1783
1905
  ignoredIdentifier,
1906
+ isIndexProbablyDroppable,
1907
+ isIndexSupported,
1784
1908
  parseNudges,
1785
1909
  permuteWithFeedback
1786
1910
  };