@query-doctor/core 0.0.3 → 0.0.4

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 +222 -97
  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 +218 -97
  6. package/dist/index.js.map +1 -1
  7. package/dist/optimizer/genalgo.d.ts +9 -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 +2 -2
  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) {
@@ -1144,13 +1271,7 @@ var ExportedStatsStatistics = z2.object({
1144
1271
  });
1145
1272
  var ExportedStatsColumns = z2.object({
1146
1273
  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()
1274
+ stats: ExportedStatsStatistics.nullable()
1154
1275
  });
1155
1276
  var ExportedStatsIndex = z2.object({
1156
1277
  indexName: z2.string(),
@@ -1618,12 +1739,6 @@ var _Statistics = class _Statistics {
1618
1739
  json_agg(
1619
1740
  json_build_object(
1620
1741
  '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
1742
  'stats', (
1628
1743
  SELECT json_build_object(
1629
1744
  'starelid', s.starelid,
@@ -1719,6 +1834,7 @@ var _Statistics = class _Statistics {
1719
1834
  COALESCE(pt.parent_table::text, t.relname) AS table_name,
1720
1835
  i.relname AS index_name,
1721
1836
  ix.indisprimary as is_primary,
1837
+ ix.indisunique as is_unique,
1722
1838
  am.amname AS index_type,
1723
1839
  array_agg(
1724
1840
  CASE
@@ -1749,9 +1865,10 @@ var _Statistics = class _Statistics {
1749
1865
  LEFT JOIN pg_attribute a ON a.attnum = k.attnum AND a.attrelid = t.oid
1750
1866
  JOIN pg_namespace n ON t.relnamespace = n.oid
1751
1867
  WHERE
1752
- n.nspname = 'public'
1868
+ n.nspname not like 'pg_%' and
1869
+ n.nspname <> 'information_schema'
1753
1870
  GROUP BY
1754
- n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary
1871
+ n.nspname, COALESCE(pt.parent_table::text, t.relname), i.relname, am.amname, ix.indisprimary, ix.indisunique
1755
1872
  ORDER BY
1756
1873
  COALESCE(pt.parent_table::text, t.relname), i.relname; -- @qd_introspection
1757
1874
  `);
@@ -1774,13 +1891,17 @@ export {
1774
1891
  ExportedStatsV1,
1775
1892
  IndexOptimizer,
1776
1893
  PROCEED,
1894
+ PgIdentifier,
1777
1895
  PostgresQueryBuilder,
1778
1896
  PostgresVersion,
1779
1897
  SKIP,
1780
1898
  Statistics,
1781
1899
  StatisticsMode,
1782
1900
  StatisticsSource,
1901
+ dropIndex,
1783
1902
  ignoredIdentifier,
1903
+ isIndexProbablyDroppable,
1904
+ isIndexSupported,
1784
1905
  parseNudges,
1785
1906
  permuteWithFeedback
1786
1907
  };