@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
@@ -0,0 +1,360 @@
1
+ const relationKinds = {
2
+ r: 'table',
3
+ p: 'table',
4
+ v: 'view',
5
+ m: 'materialized view',
6
+ f: 'foreign table',
7
+ };
8
+ // The order kinds are listed in: what holds other things first.
9
+ const kindOrder = [
10
+ 'extension',
11
+ 'schema',
12
+ 'type',
13
+ 'sequence',
14
+ 'table',
15
+ 'foreign table',
16
+ 'view',
17
+ 'materialized view',
18
+ 'function',
19
+ ];
20
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ /**
22
+ * The text without its own schema's name in front of the names in it, so that "app.users" and
23
+ * "app_copy.users" read the same when one schema is compared with another. Names in other schemas
24
+ * keep theirs.
25
+ */
26
+ export function unqualify(text, schema) {
27
+ const quoted = `"${schema.replaceAll('"', '""')}".`;
28
+ let out = text.split(quoted).join('');
29
+ if (/^[a-z_][a-z0-9_$]*$/.test(schema)) {
30
+ out = out.replace(new RegExp(`(?<![\\w$".])${escapeRegExp(schema)}\\.(?=[\\w"])`, 'g'), '');
31
+ }
32
+ return out;
33
+ }
34
+ // Excludes what an extension created, which its own version already accounts for.
35
+ const notInExtension = (catalog, oid) => `NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend e
36
+ WHERE e.classid = '${catalog}'::regclass AND e.objid = ${oid} AND e.deptype = 'e')`;
37
+ const userSchema = (column) => `${column} !~ '^pg_' AND ${column} <> 'information_schema' AND ($1::text IS NULL OR ${column} = $1)`;
38
+ /** The structure of a database (or one schema of it) as text, for comparing with another. */
39
+ export async function snapshotSchema(session, options = {}) {
40
+ const filter = [options.schema ?? null];
41
+ // With an empty search path Postgres writes every name with its schema, the same way on both
42
+ // sides, whatever each environment's search path is. Put back afterwards for the next statement.
43
+ const [saved] = await session.rows(`SELECT current_setting('search_path') AS path, pg_catalog.set_config('search_path', '', false)`);
44
+ try {
45
+ return { objects: await readObjects(session, filter) };
46
+ }
47
+ finally {
48
+ await session.rows(`SELECT pg_catalog.set_config('search_path', $1, false)`, [
49
+ saved?.path ?? '',
50
+ ]);
51
+ }
52
+ }
53
+ async function readObjects(session, filter) {
54
+ const objects = new Map();
55
+ const add = (kind, schema, name) => {
56
+ const key = objectKey({ kind, schema, name });
57
+ let object = objects.get(key);
58
+ if (!object) {
59
+ object = { kind, schema, name, parts: {} };
60
+ objects.set(key, object);
61
+ }
62
+ return object;
63
+ };
64
+ // Parts of a table, view, ... by its schema and name, whose kind is already known.
65
+ const relations = new Map();
66
+ const partsOf = (schema, name) => relations.get(`${schema}\0${name}`);
67
+ const set = (parts, part, value, schema) => {
68
+ if (parts)
69
+ parts[part] = unqualify(value, schema ?? '');
70
+ };
71
+ for (const row of await session.rows(`SELECT n.nspname AS name FROM pg_catalog.pg_namespace n
72
+ WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_namespace', 'n.oid')}`, filter)) {
73
+ add('schema', row.name ?? '', row.name ?? '');
74
+ }
75
+ for (const row of await session.rows(`SELECT x.extname AS name, x.extversion AS version, n.nspname AS schema
76
+ FROM pg_catalog.pg_extension x JOIN pg_catalog.pg_namespace n ON n.oid = x.extnamespace`)) {
77
+ const parts = add('extension', '', row.name ?? '').parts;
78
+ parts.version = row.version ?? '';
79
+ parts.schema = row.schema ?? '';
80
+ }
81
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
82
+ CASE WHEN c.relkind IN ('v', 'm') THEN pg_catalog.pg_get_viewdef(c.oid, true) END AS definition,
83
+ CASE WHEN c.relkind = 'p' THEN pg_catalog.pg_get_partkeydef(c.oid) END AS partition_key,
84
+ (SELECT pg_catalog.format('%I.%I', pn.nspname, pc.relname)
85
+ FROM pg_catalog.pg_inherits i
86
+ JOIN pg_catalog.pg_class pc ON pc.oid = i.inhparent
87
+ JOIN pg_catalog.pg_namespace pn ON pn.oid = pc.relnamespace
88
+ WHERE i.inhrelid = c.oid AND c.relispartition) AS parent,
89
+ CASE WHEN c.relispartition THEN pg_catalog.pg_get_expr(c.relpartbound, c.oid) END AS bound,
90
+ c.relrowsecurity AS rls
91
+ FROM pg_catalog.pg_class c
92
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
93
+ WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f') AND ${userSchema('n.nspname')}
94
+ AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
95
+ const parts = add(relationKinds[row.kind ?? ''] ?? 'table', row.schema ?? '', row.name ?? '').parts;
96
+ relations.set(`${row.schema}\0${row.name}`, parts);
97
+ if (row.definition !== null)
98
+ set(parts, 'definition', row.definition.trim(), row.schema);
99
+ if (row.partition_key !== null)
100
+ set(parts, 'partitioned by', row.partition_key, row.schema);
101
+ if (row.parent !== null)
102
+ set(parts, 'partition of', `${row.parent} ${row.bound ?? ''}`.trim(), row.schema);
103
+ if (row.rls === 't')
104
+ parts['row level security'] = 'enabled';
105
+ }
106
+ // Columns of tables and foreign tables; a view's follow from its definition.
107
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
108
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, a.attnotnull AS not_null,
109
+ pg_catalog.pg_get_expr(d.adbin, d.adrelid) AS default, a.attidentity AS identity,
110
+ a.attgenerated AS generated,
111
+ CASE WHEN a.attcollation <> t.typcollation THEN
112
+ (SELECT pg_catalog.format('%I.%I', cn.nspname, co.collname) FROM pg_catalog.pg_collation co
113
+ JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace WHERE co.oid = a.attcollation)
114
+ END AS collation
115
+ FROM pg_catalog.pg_attribute a
116
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
117
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
118
+ JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
119
+ LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
120
+ WHERE c.relkind IN ('r', 'p', 'f') AND a.attnum > 0 AND NOT a.attisdropped
121
+ AND ${userSchema('n.nspname')}
122
+ ORDER BY a.attnum`, filter)) {
123
+ const words = [row.type ?? ''];
124
+ if (row.collation)
125
+ words.push(`COLLATE ${row.collation}`);
126
+ if (row.not_null === 't')
127
+ words.push('NOT NULL');
128
+ if (row.identity === 'a')
129
+ words.push('GENERATED ALWAYS AS IDENTITY');
130
+ else if (row.identity === 'd')
131
+ words.push('GENERATED BY DEFAULT AS IDENTITY');
132
+ else if (row.generated === 's')
133
+ words.push(`GENERATED ALWAYS AS (${row.default}) STORED`);
134
+ else if (row.generated === 'v')
135
+ words.push(`GENERATED ALWAYS AS (${row.default}) VIRTUAL`);
136
+ else if (row.default !== null)
137
+ words.push(`DEFAULT ${row.default}`);
138
+ set(partsOf(row.schema, row.table), `column ${row.name}`, words.join(' '), row.schema);
139
+ }
140
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, i.relname AS name,
141
+ pg_catalog.pg_get_indexdef(x.indexrelid) AS definition
142
+ FROM pg_catalog.pg_index x
143
+ JOIN pg_catalog.pg_class i ON i.oid = x.indexrelid
144
+ JOIN pg_catalog.pg_class c ON c.oid = x.indrelid
145
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
146
+ WHERE ${userSchema('n.nspname')}
147
+ ORDER BY x.indisprimary DESC, i.relname`, filter)) {
148
+ set(partsOf(row.schema, row.table), `index ${row.name}`, row.definition ?? '', row.schema);
149
+ }
150
+ // Not-null constraints (Postgres 18) are left out: the column already says NOT NULL.
151
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, k.conname AS name,
152
+ pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
153
+ FROM pg_catalog.pg_constraint k
154
+ JOIN pg_catalog.pg_class c ON c.oid = k.conrelid
155
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
156
+ WHERE k.contype <> 'n' AND ${userSchema('n.nspname')}
157
+ ORDER BY array_position(ARRAY['p', 'u', 'f', 'c', 'x', 't']::"char"[], k.contype), k.conname`, filter)) {
158
+ set(partsOf(row.schema, row.table), `constraint ${row.name}`, row.definition ?? '', row.schema);
159
+ }
160
+ for (const row of await session.rows(`SELECT n.nspname AS schema, c.relname AS table, t.tgname AS name,
161
+ pg_catalog.pg_get_triggerdef(t.oid, true) AS definition, t.tgenabled AS enabled
162
+ FROM pg_catalog.pg_trigger t
163
+ JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
164
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
165
+ WHERE NOT t.tgisinternal AND ${userSchema('n.nspname')}
166
+ ORDER BY t.tgname`, filter)) {
167
+ const disabled = row.enabled === 'D' ? ' (disabled)' : '';
168
+ set(partsOf(row.schema, row.table), `trigger ${row.name}`, `${row.definition}${disabled}`, row.schema);
169
+ }
170
+ for (const row of await session.rows(`SELECT schemaname AS schema, tablename AS table, policyname AS name, permissive,
171
+ array_to_string(roles, ', ') AS roles, cmd AS command, qual, with_check
172
+ FROM pg_catalog.pg_policies
173
+ WHERE ${userSchema('schemaname')}
174
+ ORDER BY policyname`, filter)) {
175
+ const words = [`AS ${row.permissive} FOR ${row.command} TO ${row.roles}`];
176
+ if (row.qual !== null)
177
+ words.push(`USING (${row.qual})`);
178
+ if (row.with_check !== null)
179
+ words.push(`WITH CHECK (${row.with_check})`);
180
+ set(partsOf(row.schema, row.table), `policy ${row.name}`, words.join(' '), row.schema);
181
+ }
182
+ for (const row of await session.rows(`SELECT n.nspname AS schema, p.proname AS name, pg_catalog.oidvectortypes(p.proargtypes) AS args,
183
+ p.prokind AS kind,
184
+ CASE WHEN p.prokind <> 'a' THEN pg_catalog.pg_get_functiondef(p.oid) END AS definition,
185
+ pg_catalog.pg_get_function_result(p.oid) AS returns
186
+ FROM pg_catalog.pg_proc p
187
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
188
+ WHERE ${userSchema('n.nspname')} AND ${notInExtension('pg_catalog.pg_proc', 'p.oid')}`, filter)) {
189
+ const schema = row.schema ?? '';
190
+ const name = `${row.name}(${unqualify(row.args ?? '', schema)})`;
191
+ const parts = add('function', schema, name).parts;
192
+ if (row.definition !== null)
193
+ set(parts, 'definition', row.definition.trim(), schema);
194
+ else
195
+ set(parts, 'aggregate returning', row.returns ?? '', schema);
196
+ }
197
+ for (const row of await session.rows(`SELECT s.schemaname AS schema, s.sequencename AS name, s.data_type::text AS type,
198
+ s.start_value::text AS start, s.increment_by::text AS increment, s.min_value::text AS min,
199
+ s.max_value::text AS max, s.cache_size::text AS cache, s.cycle,
200
+ (SELECT pg_catalog.format('%I.%I.%I', rn.nspname, r.relname, a.attname)
201
+ FROM pg_catalog.pg_depend d
202
+ JOIN pg_catalog.pg_class r ON r.oid = d.refobjid
203
+ JOIN pg_catalog.pg_namespace rn ON rn.oid = r.relnamespace
204
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = d.refobjid AND a.attnum = d.refobjsubid
205
+ WHERE d.objid = c.oid AND d.classid = 'pg_catalog.pg_class'::regclass
206
+ AND d.refclassid = 'pg_catalog.pg_class'::regclass AND d.deptype IN ('a', 'i')
207
+ LIMIT 1) AS owned_by
208
+ FROM pg_catalog.pg_sequences s
209
+ JOIN pg_catalog.pg_namespace n ON n.nspname = s.schemaname
210
+ JOIN pg_catalog.pg_class c ON c.relname = s.sequencename AND c.relnamespace = n.oid
211
+ WHERE ${userSchema('s.schemaname')} AND ${notInExtension('pg_catalog.pg_class', 'c.oid')}`, filter)) {
212
+ const schema = row.schema ?? '';
213
+ const parts = add('sequence', schema, row.name ?? '').parts;
214
+ for (const field of ['type', 'start', 'increment', 'min', 'max', 'cache']) {
215
+ parts[field] = row[field] ?? '';
216
+ }
217
+ parts.cycle = row.cycle === 't' ? 'yes' : 'no';
218
+ if (row.owned_by !== null)
219
+ set(parts, 'owned by', row.owned_by ?? '', schema);
220
+ }
221
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS name, t.typtype AS kind,
222
+ (SELECT string_agg(pg_catalog.quote_literal(e.enumlabel), ', ' ORDER BY e.enumsortorder)
223
+ FROM pg_catalog.pg_enum e WHERE e.enumtypid = t.oid) AS labels,
224
+ CASE t.typtype
225
+ WHEN 'd' THEN pg_catalog.format_type(t.typbasetype, t.typtypmod)
226
+ WHEN 'r' THEN (SELECT pg_catalog.format_type(r.rngsubtype, NULL)
227
+ FROM pg_catalog.pg_range r WHERE r.rngtypid = t.oid)
228
+ END AS base,
229
+ t.typdefault AS default, t.typnotnull AS not_null
230
+ FROM pg_catalog.pg_type t
231
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
232
+ WHERE t.typtype IN ('e', 'c', 'd', 'r') AND ${userSchema('n.nspname')}
233
+ AND (t.typtype <> 'c' OR EXISTS (
234
+ SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
235
+ AND ${notInExtension('pg_catalog.pg_type', 't.oid')}`, filter)) {
236
+ const schema = row.schema ?? '';
237
+ const parts = add('type', schema, row.name ?? '').parts;
238
+ if (row.kind === 'e')
239
+ parts['enum labels'] = row.labels ?? '';
240
+ if (row.kind === 'd') {
241
+ set(parts, 'domain of', row.base ?? '', schema);
242
+ if (row.default !== null)
243
+ set(parts, 'default', row.default, schema);
244
+ if (row.not_null === 't')
245
+ parts['not null'] = 'yes';
246
+ }
247
+ if (row.kind === 'r')
248
+ set(parts, 'range of', row.base ?? '', schema);
249
+ }
250
+ // The attributes of composite types and the constraints of domains.
251
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, a.attname AS name,
252
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type_of
253
+ FROM pg_catalog.pg_type t
254
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
255
+ JOIN pg_catalog.pg_class c ON c.oid = t.typrelid AND c.relkind = 'c'
256
+ JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
257
+ WHERE ${userSchema('n.nspname')}
258
+ ORDER BY a.attnum`, filter)) {
259
+ const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
260
+ if (object)
261
+ set(object.parts, `attribute ${row.name}`, row.type_of ?? '', row.schema);
262
+ }
263
+ for (const row of await session.rows(`SELECT n.nspname AS schema, t.typname AS type, k.conname AS name,
264
+ pg_catalog.pg_get_constraintdef(k.oid, true) AS definition
265
+ FROM pg_catalog.pg_constraint k
266
+ JOIN pg_catalog.pg_type t ON t.oid = k.contypid
267
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
268
+ WHERE ${userSchema('n.nspname')}
269
+ ORDER BY k.conname`, filter)) {
270
+ const object = objects.get(objectKey({ kind: 'type', schema: row.schema ?? '', name: row.type ?? '' }));
271
+ if (object)
272
+ set(object.parts, `constraint ${row.name}`, row.definition ?? '', row.schema);
273
+ }
274
+ return [...objects.values()];
275
+ }
276
+ const objectKey = (object) => `${object.kind}\0${object.schema}\0${object.name}`;
277
+ // Beyond this many lines a side the comparison is not worth its time: all of a, then all of b.
278
+ const MAX_DIFF_LINES = 3000;
279
+ /** The lines of a and b in order, those they share once, by the longest common subsequence. */
280
+ export function diffLines(a, b) {
281
+ const left = a.split('\n');
282
+ const right = b.split('\n');
283
+ if (left.length > MAX_DIFF_LINES || right.length > MAX_DIFF_LINES) {
284
+ return [
285
+ ...left.map((text) => ({ side: 'a', text })),
286
+ ...right.map((text) => ({ side: 'b', text })),
287
+ ];
288
+ }
289
+ // common[i][j]: how many lines left[i..] and right[j..] have in common.
290
+ const common = Array.from({ length: left.length + 1 }, () => new Uint32Array(right.length + 1));
291
+ for (let i = left.length - 1; i >= 0; i--) {
292
+ for (let j = right.length - 1; j >= 0; j--) {
293
+ common[i][j] =
294
+ left[i] === right[j]
295
+ ? common[i + 1][j + 1] + 1
296
+ : Math.max(common[i + 1][j], common[i][j + 1]);
297
+ }
298
+ }
299
+ const lines = [];
300
+ let i = 0;
301
+ let j = 0;
302
+ while (i < left.length || j < right.length) {
303
+ if (i < left.length && j < right.length && left[i] === right[j]) {
304
+ lines.push({ side: 'both', text: left[i] });
305
+ i++;
306
+ j++;
307
+ }
308
+ else if (j >= right.length || (i < left.length && common[i + 1][j] >= common[i][j + 1])) {
309
+ lines.push({ side: 'a', text: left[i++] });
310
+ }
311
+ else {
312
+ lines.push({ side: 'b', text: right[j++] });
313
+ }
314
+ }
315
+ return lines;
316
+ }
317
+ const partChange = (part, a, b) => a !== null && b !== null && (a.includes('\n') || b.includes('\n'))
318
+ ? { part, a, b, lines: diffLines(a, b) }
319
+ : { part, a, b };
320
+ const sortKey = (object) => `${object.schema}\0${String(kindOrder.indexOf(object.kind)).padStart(2, '0')}\0${object.name}`;
321
+ /** The objects and parts that differ between two snapshots, by schema, kind and name. */
322
+ export function diffSnapshots(a, b, options = {}) {
323
+ const { schemas } = options;
324
+ // Side b's objects named as though in side a's schema, so the two line up.
325
+ const rename = (object) => schemas && object.schema === schemas.b
326
+ ? { ...object, schema: schemas.a, name: object.kind === 'schema' ? schemas.a : object.name }
327
+ : object;
328
+ const left = new Map(a.objects.map((object) => [objectKey(object), object]));
329
+ const right = new Map(b.objects.map(rename).map((object) => [objectKey(object), object]));
330
+ const changes = [];
331
+ let same = 0;
332
+ for (const key of new Set([...left.keys(), ...right.keys()])) {
333
+ const one = left.get(key);
334
+ const other = right.get(key);
335
+ const object = (one ?? other);
336
+ const names = [
337
+ ...new Set([...Object.keys(one?.parts ?? {}), ...Object.keys(other?.parts ?? {})]),
338
+ ];
339
+ const parts = names
340
+ .filter((part) => !one || !other || one.parts[part] !== other.parts[part])
341
+ .map((part) => partChange(part, one?.parts[part] ?? null, other?.parts[part] ?? null));
342
+ const status = !other ? 'only-a' : !one ? 'only-b' : parts.length > 0 ? 'changed' : null;
343
+ if (status)
344
+ changes.push({ kind: object.kind, schema: object.schema, name: object.name, status, parts });
345
+ else
346
+ same++;
347
+ }
348
+ changes.sort((x, y) => sortKey(x).localeCompare(sortKey(y)));
349
+ return { changes, same };
350
+ }
351
+ /** Reads both sides at once (one after the other on the same session) and compares them. */
352
+ export async function compareSchemas(a, b, options = {}) {
353
+ const { schema } = options;
354
+ const withSchema = options.withSchema ?? schema;
355
+ const readA = () => snapshotSchema(a, schema ? { schema } : {});
356
+ const readB = () => snapshotSchema(b, withSchema ? { schema: withSchema } : {});
357
+ // One connection runs one statement at a time, and each read changes its search path meanwhile.
358
+ const [left, right] = a === b ? [await readA(), await readB()] : await Promise.all([readA(), readB()]);
359
+ return diffSnapshots(left, right, schema && withSchema && schema !== withSchema ? { schemas: { a: schema, b: withSchema } } : {});
360
+ }
@@ -0,0 +1,309 @@
1
+ // Finding things in a database: a flat index of its object names for a search box, and a search for
2
+ // a value through the text, uuid and integer columns of its tables.
3
+ import { quoteIdentifier } from '../db/identifiers.js';
4
+ import { qualified } from './catalog.js';
5
+ export const MAX_INDEXED_COLUMNS = 50_000;
6
+ const relationKinds = {
7
+ r: 'table',
8
+ p: 'partitioned',
9
+ v: 'view',
10
+ m: 'materialized',
11
+ f: 'foreign',
12
+ };
13
+ // The schemas a person works with, as in listSchemas.
14
+ const userSchema = `n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'`;
15
+ /**
16
+ * Every table, view, column, function, sequence, type and enum value of the schemas a person works
17
+ * with, with comments, for matching against what is typed in a search box.
18
+ */
19
+ export async function buildSearchIndex(session) {
20
+ const entries = [];
21
+ const relations = await session.rows(`SELECT n.nspname AS schema, c.relname AS name, c.relkind AS kind,
22
+ pg_catalog.obj_description(c.oid, 'pg_class') AS comment
23
+ FROM pg_catalog.pg_class c
24
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
25
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
26
+ ORDER BY n.nspname, c.relname`);
27
+ for (const row of relations) {
28
+ entries.push({
29
+ kind: relationKinds[row.kind ?? ''] ?? 'table',
30
+ schema: row.schema ?? '',
31
+ name: row.name ?? '',
32
+ parent: null,
33
+ detail: null,
34
+ comment: row.comment,
35
+ });
36
+ }
37
+ const columns = await session.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS name,
38
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type,
39
+ pg_catalog.col_description(a.attrelid, a.attnum) AS comment
40
+ FROM pg_catalog.pg_attribute a
41
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
42
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
43
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
44
+ AND a.attnum > 0 AND NOT a.attisdropped
45
+ ORDER BY n.nspname, c.relname, a.attnum
46
+ LIMIT $1`, [MAX_INDEXED_COLUMNS + 1]);
47
+ const truncated = columns.length > MAX_INDEXED_COLUMNS;
48
+ for (const row of columns.slice(0, MAX_INDEXED_COLUMNS)) {
49
+ entries.push({
50
+ kind: 'column',
51
+ schema: row.schema ?? '',
52
+ name: row.name ?? '',
53
+ parent: row.table,
54
+ detail: row.type,
55
+ comment: row.comment,
56
+ });
57
+ }
58
+ const labels = await session.rows(`SELECT n.nspname AS schema, t.typname AS type, e.enumlabel AS label
59
+ FROM pg_catalog.pg_enum e
60
+ JOIN pg_catalog.pg_type t ON t.oid = e.enumtypid
61
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
62
+ WHERE ${userSchema}
63
+ ORDER BY n.nspname, t.typname, e.enumsortorder`);
64
+ for (const row of labels) {
65
+ entries.push({
66
+ kind: 'enum value',
67
+ schema: row.schema ?? '',
68
+ name: row.label ?? '',
69
+ parent: row.type,
70
+ detail: null,
71
+ comment: null,
72
+ });
73
+ }
74
+ const functions = await session.rows(`SELECT n.nspname AS schema, p.proname AS name,
75
+ pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
76
+ pg_catalog.obj_description(p.oid, 'pg_proc') AS comment
77
+ FROM pg_catalog.pg_proc p
78
+ JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
79
+ WHERE ${userSchema}
80
+ ORDER BY n.nspname, p.proname, args`);
81
+ for (const row of functions) {
82
+ entries.push({
83
+ kind: 'function',
84
+ schema: row.schema ?? '',
85
+ name: row.name ?? '',
86
+ parent: null,
87
+ detail: row.args ?? '',
88
+ comment: row.comment,
89
+ });
90
+ }
91
+ const sequences = await session.rows(`SELECT schemaname AS schema, sequencename AS name FROM pg_catalog.pg_sequences
92
+ WHERE schemaname !~ '^pg_' AND schemaname <> 'information_schema'
93
+ ORDER BY schemaname, sequencename`);
94
+ for (const row of sequences) {
95
+ entries.push({
96
+ kind: 'sequence',
97
+ schema: row.schema ?? '',
98
+ name: row.name ?? '',
99
+ parent: null,
100
+ detail: null,
101
+ comment: null,
102
+ });
103
+ }
104
+ const types = await session.rows(`SELECT n.nspname AS schema, t.typname AS name, t.typtype AS kind,
105
+ pg_catalog.obj_description(t.oid, 'pg_type') AS comment
106
+ FROM pg_catalog.pg_type t
107
+ JOIN pg_catalog.pg_namespace n ON n.oid = t.typnamespace
108
+ WHERE ${userSchema} AND t.typtype IN ('e', 'c', 'd', 'r', 'm')
109
+ AND (t.typtype <> 'c' OR EXISTS (
110
+ SELECT 1 FROM pg_catalog.pg_class c WHERE c.oid = t.typrelid AND c.relkind = 'c'))
111
+ ORDER BY n.nspname, t.typname`);
112
+ const typeKinds = {
113
+ e: 'enum',
114
+ c: 'composite',
115
+ d: 'domain',
116
+ r: 'range',
117
+ m: 'multirange',
118
+ };
119
+ for (const row of types) {
120
+ entries.push({
121
+ kind: 'type',
122
+ schema: row.schema ?? '',
123
+ name: row.name ?? '',
124
+ parent: null,
125
+ detail: typeKinds[row.kind ?? ''] ?? null,
126
+ comment: row.comment,
127
+ });
128
+ }
129
+ return { entries, truncated };
130
+ }
131
+ export const DEFAULT_TABLE_TIMEOUT_MS = 5_000;
132
+ export const DEFAULT_DEADLINE_MS = 30_000;
133
+ export const DEFAULT_MAX_TABLES = 200;
134
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
135
+ const integerPattern = /^[+-]?\d{1,18}$/;
136
+ const valueKinds = {
137
+ text: 'text',
138
+ varchar: 'text',
139
+ bpchar: 'text',
140
+ name: 'text',
141
+ citext: 'text',
142
+ uuid: 'uuid',
143
+ int2: 'integer',
144
+ int4: 'integer',
145
+ int8: 'integer',
146
+ };
147
+ /** The column kinds a text can be compared with, e.g. a uuid is only compared with uuid columns. */
148
+ export function comparableKinds(text) {
149
+ const kinds = new Set(['text']);
150
+ if (uuidPattern.test(text))
151
+ kinds.add('uuid');
152
+ if (integerPattern.test(text))
153
+ kinds.add('integer');
154
+ return kinds;
155
+ }
156
+ /** Escapes the characters that mean something to LIKE. */
157
+ export const escapeLike = (text) => text.replace(/[\\%_]/g, (char) => `\\${char}`);
158
+ const likePattern = (text) => `%${escapeLike(text)}%`;
159
+ const quoteLiteral = (text) => `'${text.replaceAll("'", "''")}'`;
160
+ /**
161
+ * The condition that finds `text` in a column of the given kind. A text is compared by `$1`, which
162
+ * holds a LIKE pattern for it; a uuid and an integer are written out, which is safe because
163
+ * `comparableKinds` only lets through text of exactly their shape.
164
+ */
165
+ const conditionFor = (column, kind, text) => kind === 'text'
166
+ ? `${column} ILIKE $1`
167
+ : kind === 'uuid'
168
+ ? `${column} = ${quoteLiteral(text)}::uuid`
169
+ : `${column} = ${text.replace(/^\+/, '')}`;
170
+ /** A statement that lists the rows of a table holding the text in one column. */
171
+ export function valueSql(table, column, kind, text) {
172
+ const name = quoteIdentifier(column);
173
+ const where = kind === 'text'
174
+ ? `${name} ILIKE ${quoteLiteral(likePattern(text))}`
175
+ : conditionFor(name, kind, text);
176
+ return `SELECT * FROM ${qualified(table)} WHERE ${where} LIMIT 100`;
177
+ }
178
+ const timedOut = (error) => error?.code === '57014';
179
+ /**
180
+ * Looks for `text` in every text, uuid and integer column of the tables of a schema (or of all),
181
+ * reading each table once. Given several sessions, the tables are shared out over them, one table at a time on
182
+ * each, so more sessions read more tables side by side. The work is bounded: a table that takes
183
+ * longer than `tableTimeoutMs` is skipped, and so is everything left once `deadlineMs` has passed
184
+ * or `maxTables` are done, each with the reason in the result. Views are not read. Only SELECT
185
+ * statements are run.
186
+ */
187
+ export async function searchValues(sessionOrSessions, { text, schema }, { tableTimeoutMs = DEFAULT_TABLE_TIMEOUT_MS, deadlineMs = DEFAULT_DEADLINE_MS, maxTables = DEFAULT_MAX_TABLES, } = {}) {
188
+ const sessions = Array.isArray(sessionOrSessions)
189
+ ? sessionOrSessions
190
+ : [sessionOrSessions];
191
+ const [first] = sessions;
192
+ if (!first)
193
+ throw new Error('searchValues needs a session');
194
+ const kinds = comparableKinds(text);
195
+ const found = await first.rows(`SELECT n.nspname AS schema, c.relname AS table, a.attname AS column,
196
+ pg_catalog.format_type(a.atttypid, a.atttypmod) AS type, t.typname AS udt
197
+ FROM pg_catalog.pg_attribute a
198
+ JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
199
+ JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
200
+ JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
201
+ WHERE ${userSchema} AND c.relkind IN ('r', 'p')
202
+ AND ($1::text IS NULL OR n.nspname = $1)
203
+ AND a.attnum > 0 AND NOT a.attisdropped
204
+ AND t.typname = ANY($2::text[])
205
+ AND pg_catalog.has_column_privilege(a.attrelid, a.attnum, 'SELECT')
206
+ ORDER BY n.nspname, c.relname, a.attnum`, [schema ?? null, Object.keys(valueKinds)]);
207
+ // Group the columns that can be compared with the text by table.
208
+ const tables = new Map();
209
+ for (const row of found) {
210
+ const kind = valueKinds[row.udt ?? ''];
211
+ if (!kind || !kinds.has(kind))
212
+ continue;
213
+ const table = { schema: row.schema ?? '', name: row.table ?? '' };
214
+ const key = `${table.schema}\0${table.name}`;
215
+ const entry = tables.get(key) ?? { table, columns: [] };
216
+ entry.columns.push({ name: row.column ?? '', type: row.type ?? '', kind });
217
+ tables.set(key, entry);
218
+ }
219
+ const started = Date.now();
220
+ const queue = [...tables.values()];
221
+ // One slot per table, so the result reads the same however the sessions interleave.
222
+ const outcomes = [];
223
+ let next = 0;
224
+ // Tables being read or read successfully, against maxTables.
225
+ let used = 0;
226
+ // The timeout belongs to the session, so put the old one back when done.
227
+ const worker = async (session) => {
228
+ const [before] = await session.rows("SELECT current_setting('statement_timeout') AS timeout");
229
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
230
+ String(tableTimeoutMs),
231
+ ]);
232
+ try {
233
+ for (;;) {
234
+ const index = next++;
235
+ const item = queue[index];
236
+ if (!item)
237
+ return;
238
+ const { table, columns } = item;
239
+ if (used >= maxTables) {
240
+ outcomes[index] = { skipped: `only ${maxTables} tables are searched at a time` };
241
+ continue;
242
+ }
243
+ if (Date.now() - started > deadlineMs) {
244
+ outcomes[index] = { skipped: 'ran out of time' };
245
+ continue;
246
+ }
247
+ used++;
248
+ // One pass over the table counts the rows for every column at once.
249
+ const counts = columns
250
+ .map((column, i) => {
251
+ const condition = conditionFor(quoteIdentifier(column.name), column.kind, text);
252
+ return `count(*) FILTER (WHERE ${condition}) AS c${i}`;
253
+ })
254
+ .join(', ');
255
+ // A parameter nobody uses has no type, so it is only sent along with a text column.
256
+ const values = columns.some((column) => column.kind === 'text') ? [likePattern(text)] : [];
257
+ try {
258
+ const [row] = await session.rows(`SELECT ${counts} FROM ${qualified(table)}`, values);
259
+ outcomes[index] = {
260
+ matches: columns.flatMap((column, i) => {
261
+ const count = Number(row?.[`c${i}`] ?? 0);
262
+ return count > 0
263
+ ? [
264
+ {
265
+ ...table,
266
+ column: column.name,
267
+ type: column.type,
268
+ matches: count,
269
+ sql: valueSql(table, column.name, column.kind, text),
270
+ },
271
+ ]
272
+ : [];
273
+ }),
274
+ };
275
+ }
276
+ catch (error) {
277
+ used--;
278
+ outcomes[index] = {
279
+ skipped: timedOut(error)
280
+ ? `took longer than ${Math.round(tableTimeoutMs / 1000)}s`
281
+ : error instanceof Error
282
+ ? error.message
283
+ : String(error),
284
+ };
285
+ }
286
+ }
287
+ }
288
+ finally {
289
+ await session.rows("SELECT pg_catalog.set_config('statement_timeout', $1, false)", [
290
+ before?.timeout ?? '0',
291
+ ]);
292
+ }
293
+ };
294
+ await Promise.all(sessions.map(worker));
295
+ const matches = [];
296
+ const skipped = [];
297
+ let searched = 0;
298
+ outcomes.forEach((outcome, index) => {
299
+ if ('skipped' in outcome)
300
+ skipped.push({ table: queue[index].table, reason: outcome.skipped });
301
+ else {
302
+ searched++;
303
+ matches.push(...outcome.matches);
304
+ }
305
+ });
306
+ // Most matches first.
307
+ matches.sort((a, b) => b.matches - a.matches);
308
+ return { matches, searched, skipped };
309
+ }