@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.
- package/README.md +229 -18
- package/RELEASES.md +95 -0
- package/dist/cli.js +57 -13
- package/dist/commands/configgen.js +25 -25
- package/dist/commands/explore.js +86 -4
- package/dist/config/load.js +11 -73
- package/dist/config/resolve-environment.js +10 -62
- package/dist/config/resolvers/index.js +1 -1
- package/dist/config/resolvers/prompt.js +2 -2
- package/dist/config/resolvers/vault.js +1 -83
- package/dist/db/store.js +57 -0
- package/dist/environments.js +5 -2
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +31 -0
- package/dist/explorer/connect-entry.js +29 -18
- package/dist/explorer/cypher-parse.js +478 -0
- package/dist/explorer/cypher.js +963 -0
- package/dist/explorer/ddl.js +502 -0
- package/dist/explorer/dependents.js +222 -0
- package/dist/explorer/design-load.js +254 -0
- package/dist/explorer/design.js +45 -0
- package/dist/explorer/edit.js +108 -0
- package/dist/explorer/explain.js +44 -3
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/graph.js +679 -0
- package/dist/explorer/gucs.js +31 -0
- package/dist/explorer/profile.js +102 -0
- package/dist/explorer/references.js +95 -0
- package/dist/explorer/rows.js +100 -13
- package/dist/explorer/schema-diff.js +360 -0
- package/dist/explorer/search.js +309 -0
- package/dist/explorer/security.js +152 -0
- package/dist/explorer/seed.js +1376 -0
- package/dist/explorer/session.js +25 -1
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/screens/environments.js +6 -3
- package/dist/screens/explorer.js +0 -3
- package/dist/screens/splash.js +4 -2
- package/dist/web/server.js +655 -22
- package/package.json +4 -1
- package/web/dist/assets/antonio-latin-400-normal-B7iMKlzw.woff +0 -0
- package/web/dist/assets/antonio-latin-400-normal-CZjzwNAN.woff2 +0 -0
- package/web/dist/assets/antonio-latin-700-normal-BY3mCh47.woff +0 -0
- package/web/dist/assets/antonio-latin-700-normal-DSH-lEDG.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-nE82dizb.woff2 +0 -0
- package/web/dist/assets/antonio-latin-ext-400-normal-qA9Jb92z.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-BdSkr58W.woff +0 -0
- package/web/dist/assets/antonio-latin-ext-700-normal-D-dvgqjr.woff2 +0 -0
- package/web/dist/assets/button-BiFx4Lyw.js +1 -0
- package/web/dist/assets/code-xml-CPEjNFOw.js +1 -0
- package/web/dist/assets/compare-view-COQ3yE76.js +2 -0
- package/web/dist/assets/copy-C2N9tUYJ.js +1 -0
- package/web/dist/assets/cypher-view-BzyL4mNU.js +13 -0
- package/web/dist/assets/design-view-BPm7dSTY.js +9 -0
- package/web/dist/assets/erd-view-DqGDws2_.js +1 -0
- package/web/dist/assets/format-sql-CoddYUOF.js +5 -0
- package/web/dist/assets/graph-force-ChCGXQUt.js +1 -0
- package/web/dist/assets/graph-link-C8P9s_0W.js +1 -0
- package/web/dist/assets/graph-view-BpoUrUCP.js +3 -0
- package/web/dist/assets/index-CpHnjRLv.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-JiGCgCWS.js +1 -0
- package/web/dist/assets/loader-circle-BxDJOoJO.js +1 -0
- package/web/dist/assets/query-builder-view-CtRexMEX.js +4 -0
- package/web/dist/assets/rotate-ccw-CwdA-J3t.js +1 -0
- package/web/dist/assets/route-7eu-iAqD.js +15 -0
- package/web/dist/assets/seed-view-BIkBTiU6.js +7 -0
- package/web/dist/assets/shield-alert-keJIAND5.js +1 -0
- package/web/dist/assets/sliders-horizontal-DKtL_ql7.js +1 -0
- package/web/dist/assets/sql-tabs-DUSXdYCr.js +2 -0
- package/web/dist/assets/table-Drc_n1gF.js +1 -0
- package/web/dist/assets/triangle-alert-C5nF69rW.js +1 -0
- package/web/dist/assets/zone-CHDOrDBF.js +5 -0
- package/web/dist/index.html +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- 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
|
+
}
|