@kazurolabs/psql-explorer 0.1.0 → 0.3.1

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 (77) hide show
  1. package/README.md +229 -18
  2. package/RELEASES.md +95 -0
  3. package/dist/cli.js +57 -13
  4. package/dist/commands/configgen.js +25 -25
  5. package/dist/commands/explore.js +86 -4
  6. package/dist/config/load.js +11 -73
  7. package/dist/config/resolve-environment.js +10 -62
  8. package/dist/config/resolvers/index.js +1 -1
  9. package/dist/config/resolvers/prompt.js +2 -2
  10. package/dist/config/resolvers/vault.js +1 -83
  11. package/dist/db/store.js +57 -0
  12. package/dist/environments.js +5 -2
  13. package/dist/explorer/alter.js +493 -0
  14. package/dist/explorer/catalog.js +31 -0
  15. package/dist/explorer/connect-entry.js +29 -18
  16. package/dist/explorer/cypher-parse.js +478 -0
  17. package/dist/explorer/cypher.js +963 -0
  18. package/dist/explorer/ddl.js +502 -0
  19. package/dist/explorer/dependents.js +222 -0
  20. package/dist/explorer/design-load.js +254 -0
  21. package/dist/explorer/design.js +45 -0
  22. package/dist/explorer/edit.js +108 -0
  23. package/dist/explorer/explain.js +44 -3
  24. package/dist/explorer/filter.js +121 -0
  25. package/dist/explorer/graph.js +679 -0
  26. package/dist/explorer/gucs.js +31 -0
  27. package/dist/explorer/profile.js +102 -0
  28. package/dist/explorer/references.js +95 -0
  29. package/dist/explorer/rows.js +100 -13
  30. package/dist/explorer/schema-diff.js +360 -0
  31. package/dist/explorer/search.js +309 -0
  32. package/dist/explorer/security.js +152 -0
  33. package/dist/explorer/seed.js +1376 -0
  34. package/dist/explorer/session.js +25 -1
  35. package/dist/explorer/stats.js +135 -0
  36. package/dist/explorer/timeout.js +13 -0
  37. package/dist/screens/environments.js +6 -3
  38. package/dist/screens/explorer.js +0 -3
  39. package/dist/screens/splash.js +4 -2
  40. package/dist/web/server.js +655 -22
  41. package/package.json +4 -1
  42. package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
  43. package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
  44. package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
  45. package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
  46. package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
  47. package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
  48. package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
  49. package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
  50. package/web/dist/assets/button-BiFx4Lyw.js +1 -0
  51. package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
  52. package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
  53. package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
  54. package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
  55. package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
  56. package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
  57. package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
  58. package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
  59. package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
  60. package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
  61. package/web/dist/assets/index-CpHnjRLv.js +10 -0
  62. package/web/dist/assets/index-OzJqnJWG.css +2 -0
  63. package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
  64. package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
  65. package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
  66. package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
  67. package/web/dist/assets/route-7eu-iAqD.js +15 -0
  68. package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
  69. package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
  70. package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
  71. package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
  72. package/web/dist/assets/table-Drc_n1gF.js +1 -0
  73. package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
  74. package/web/dist/assets/zone-CHDOrDBF.js +5 -0
  75. package/web/dist/index.html +9 -2
  76. package/web/dist/assets/index-BMLFCtFY.js +0 -17
  77. package/web/dist/assets/index-CMXJRL9O.css +0 -2
@@ -1,13 +1,54 @@
1
+ // The planner's plan for a statement, as the web UI's Explain button shows it.
2
+ import { trimStatement } from './rows.js';
3
+ // Statements that only read, so that measuring them in a read only session is allowed. WITH is
4
+ // among them because the transaction they run in is read only too, which refuses a CTE that writes.
5
+ const onlyReads = /^(?:select|with|values|table)\b/i;
6
+ /** Runs `work` in a transaction that is always rolled back, whatever it did. */
7
+ async function rolledBack(session, begin, work) {
8
+ await session.run(begin);
9
+ try {
10
+ return await work();
11
+ }
12
+ finally {
13
+ await session.run('ROLLBACK').catch(() => undefined);
14
+ }
15
+ }
1
16
  /**
2
17
  * The plan for a statement, with schemas and qualified column names so that advice can name them.
3
18
  * Without ANALYZE the statement is planned but never run, so explaining
4
- * an UPDATE or DELETE changes nothing.
19
+ * an UPDATE or DELETE changes nothing. It is planned in a read only transaction that is rolled
20
+ * back, and sent as one statement, so text after a `;` is refused rather than run.
5
21
  */
6
22
  export async function explainQuery(session, sql) {
7
- const statement = sql.replace(/[\s;]+$/, '');
8
- const [row] = await session.rows(`EXPLAIN (VERBOSE, FORMAT JSON) ${statement}`);
23
+ const statement = trimStatement(sql);
24
+ const [row] = await rolledBack(session, 'BEGIN READ ONLY', () => session.rows(`EXPLAIN (VERBOSE, FORMAT JSON) ${statement}`));
9
25
  const plan = JSON.parse(row?.['QUERY PLAN'] ?? '[]');
10
26
  if (!plan[0]?.Plan)
11
27
  throw new Error('Postgres did not return a plan');
12
28
  return plan[0].Plan;
13
29
  }
30
+ /**
31
+ * Runs the statement to measure it (EXPLAIN ANALYZE), so every step of the plan has its actual
32
+ * time, rows and buffers next to the planner's estimates. It runs inside a transaction that is
33
+ * always rolled back (read only when the session is), and is sent as one statement so that no
34
+ * COMMIT can follow it; rows it inserts, updates or deletes are not kept, but what cannot be undone
35
+ * (a sequence's nextval, a NOTIFY sent by a trigger, ...) still happens. A read only session
36
+ * measures only queries.
37
+ */
38
+ export async function explainAnalyze(session, sql) {
39
+ const statement = trimStatement(sql);
40
+ if (session.readOnly && !onlyReads.test(statement)) {
41
+ throw new Error('Only a query can be measured: this environment is read only');
42
+ }
43
+ return rolledBack(session, session.readOnly ? 'BEGIN READ ONLY' : 'BEGIN', async () => {
44
+ const [row] = await session.rows(`EXPLAIN (ANALYZE, BUFFERS, VERBOSE, FORMAT JSON) ${statement}`);
45
+ const plan = JSON.parse(row?.['QUERY PLAN'] ?? '[]');
46
+ if (!plan[0]?.Plan)
47
+ throw new Error('Postgres did not return a plan');
48
+ return {
49
+ plan: plan[0].Plan,
50
+ planningMs: plan[0]['Planning Time'] ?? null,
51
+ executionMs: plan[0]['Execution Time'] ?? null,
52
+ };
53
+ });
54
+ }
@@ -0,0 +1,121 @@
1
+ // Filters on the rows of a table, as the row grid of the web page sends them.
2
+ import { quoteIdentifier } from '../db/identifiers.js';
3
+ import { escapeLike } from './search.js';
4
+ export const FILTER_OPS = [
5
+ 'eq',
6
+ 'ne',
7
+ 'contains',
8
+ 'gt',
9
+ 'gte',
10
+ 'lt',
11
+ 'lte',
12
+ 'null',
13
+ 'notnull',
14
+ 'in',
15
+ 'between',
16
+ ];
17
+ export const MAX_IN_VALUES = 100;
18
+ export class FilterError extends Error {
19
+ }
20
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
21
+ /** The values of an `in` list: commas separate them, a backslash escapes a comma or a backslash. */
22
+ export function splitList(text) {
23
+ const values = [];
24
+ let current = '';
25
+ for (let i = 0; i < text.length; i++) {
26
+ const char = text[i];
27
+ if (char === '\\' && i + 1 < text.length)
28
+ current += text[++i];
29
+ else if (char === ',') {
30
+ values.push(current.trim());
31
+ current = '';
32
+ }
33
+ else
34
+ current += char;
35
+ }
36
+ values.push(current.trim());
37
+ return values.filter((value) => value !== '');
38
+ }
39
+ const comparisons = {
40
+ eq: '=',
41
+ ne: '<>',
42
+ gt: '>',
43
+ gte: '>=',
44
+ lt: '<',
45
+ lte: '<=',
46
+ };
47
+ /**
48
+ * The WHERE clause for the filters. Columns must be ones of the table, and values are bound as
49
+ * parameters (or written as literals when `inline` is set, for showing the statement); a value is
50
+ * compared with the column's own type, so Postgres reads `2024-01-01` as a date for a date column.
51
+ */
52
+ export function buildWhere(columns, filters, { inline = false } = {}) {
53
+ const params = [];
54
+ const bind = (value) => {
55
+ if (inline)
56
+ return quoteLiteral(value);
57
+ params.push(value);
58
+ return `$${params.length}`;
59
+ };
60
+ const known = new Set(columns.map((column) => column.name));
61
+ const conditions = filters.map((filter) => {
62
+ if (!known.has(filter.column))
63
+ throw new FilterError(`no column "${filter.column}"`);
64
+ if (!FILTER_OPS.includes(filter.op))
65
+ throw new FilterError(`unknown operator "${filter.op}"`);
66
+ const name = quoteIdentifier(filter.column);
67
+ const { op } = filter;
68
+ if (op === 'null')
69
+ return `${name} IS NULL`;
70
+ if (op === 'notnull')
71
+ return `${name} IS NOT NULL`;
72
+ const value = filter.value ?? '';
73
+ if (op === 'in') {
74
+ const values = splitList(value);
75
+ if (values.length === 0)
76
+ throw new FilterError(`"${filter.column}" in needs a value`);
77
+ if (values.length > MAX_IN_VALUES) {
78
+ throw new FilterError(`in takes at most ${MAX_IN_VALUES} values`);
79
+ }
80
+ return `${name} IN (${values.map(bind).join(', ')})`;
81
+ }
82
+ if (op === 'between') {
83
+ if (value === '' || (filter.value2 ?? '') === '') {
84
+ throw new FilterError(`"${filter.column}" between needs two values`);
85
+ }
86
+ const low = bind(value);
87
+ return `${name} BETWEEN ${low} AND ${bind(filter.value2)}`;
88
+ }
89
+ if (op === 'contains')
90
+ return `${name}::text ILIKE ${bind(`%${escapeLike(value)}%`)}`;
91
+ return `${name} ${comparisons[op]} ${bind(value)}`;
92
+ });
93
+ return {
94
+ sql: conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '',
95
+ params,
96
+ };
97
+ }
98
+ /** Reads the filters of a request, which are untrusted, as `Filter`s. */
99
+ export function parseFilters(raw) {
100
+ if (raw === undefined || raw === null)
101
+ return [];
102
+ if (!Array.isArray(raw))
103
+ throw new FilterError('filters must be a list');
104
+ if (raw.length > 50)
105
+ throw new FilterError('too many filters');
106
+ return raw.map((item) => {
107
+ const entry = item;
108
+ if (typeof entry?.column !== 'string' || typeof entry.op !== 'string') {
109
+ throw new FilterError('a filter is {column, op, value}');
110
+ }
111
+ const text = (field) => (typeof field === 'string' ? field : undefined);
112
+ const value = text(entry.value);
113
+ const value2 = text(entry.value2);
114
+ return {
115
+ column: entry.column,
116
+ op: entry.op,
117
+ ...(value === undefined ? {} : { value }),
118
+ ...(value2 === undefined ? {} : { value2 }),
119
+ };
120
+ });
121
+ }