@kazurolabs/psql-explorer 0.0.0-stage → 0.3.0
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/LICENSE +21 -0
- package/README.md +505 -2
- package/RELEASES.md +87 -0
- package/dist/app.js +39 -0
- package/dist/cli.js +307 -0
- package/dist/commands/configgen.js +128 -0
- package/dist/commands/explore.js +388 -0
- package/dist/commands/terminal-prompt.js +68 -0
- package/dist/config/load.js +324 -0
- package/dist/config/resolve-environment.js +112 -0
- package/dist/config/resolvers/env.js +12 -0
- package/dist/config/resolvers/file.js +24 -0
- package/dist/config/resolvers/groups.js +1 -0
- package/dist/config/resolvers/index.js +49 -0
- package/dist/config/resolvers/literal.js +7 -0
- package/dist/config/resolvers/prompt.js +159 -0
- package/dist/config/resolvers/stored.js +13 -0
- package/dist/config/resolvers/types.js +18 -0
- package/dist/config/resolvers/vault.js +283 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/source.js +105 -0
- package/dist/db/connect.js +55 -0
- package/dist/db/identifiers.js +6 -0
- package/dist/db/paths.js +14 -0
- package/dist/db/store.js +323 -0
- package/dist/db/tester.js +190 -0
- package/dist/db/validate.js +99 -0
- package/dist/environments.js +140 -0
- package/dist/explorer/alter.js +493 -0
- package/dist/explorer/catalog.js +414 -0
- package/dist/explorer/connect-entry.js +42 -0
- 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 +37 -0
- package/dist/explorer/filter.js +121 -0
- package/dist/explorer/format.js +134 -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 +64 -0
- 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 +122 -0
- package/dist/explorer/stats.js +135 -0
- package/dist/explorer/timeout.js +13 -0
- package/dist/prompts.js +52 -0
- package/dist/screens/environments.js +229 -0
- package/dist/screens/explorer.js +145 -0
- package/dist/screens/header.js +8 -0
- package/dist/screens/index.js +13 -0
- package/dist/screens/new-connection.js +178 -0
- package/dist/screens/panes/common.js +35 -0
- package/dist/screens/panes/history.js +36 -0
- package/dist/screens/panes/info.js +61 -0
- package/dist/screens/panes/query.js +113 -0
- package/dist/screens/panes/table.js +178 -0
- package/dist/screens/panes/tables.js +142 -0
- package/dist/screens/prompt-modal.js +18 -0
- package/dist/screens/record-view.js +42 -0
- package/dist/screens/result-grid.js +126 -0
- package/dist/screens/scroll.js +12 -0
- package/dist/screens/splash.js +78 -0
- package/dist/screens/terminal.js +25 -0
- package/dist/screens/types.js +1 -0
- package/dist/services.js +1 -0
- package/dist/startup.js +84 -0
- package/dist/web/server.js +988 -0
- package/package.json +69 -5
- package/web/dist/assets/anton-latin-400-normal-AUNGEG_V.woff +0 -0
- package/web/dist/assets/anton-latin-400-normal-Byf51wtH.woff2 +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-BMODBQc6.woff +0 -0
- package/web/dist/assets/anton-latin-ext-400-normal-SyiqE2Jt.woff2 +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-2FfR1wHA.woff +0 -0
- package/web/dist/assets/anton-vietnamese-400-normal-CkBxLiRJ.woff2 +0 -0
- 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-ClA70PLR.js +1 -0
- package/web/dist/assets/code-xml-CXkpHeU9.js +1 -0
- package/web/dist/assets/compare-view-DKl5l_Jw.js +2 -0
- package/web/dist/assets/copy-CCXzeW-s.js +1 -0
- package/web/dist/assets/cypher-view-Pd0WFH8F.js +13 -0
- package/web/dist/assets/design-view-CIHSmH4u.js +9 -0
- package/web/dist/assets/erd-view-CV7mFGAN.js +1 -0
- package/web/dist/assets/format-sql-BLj5_RDZ.js +5 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/graph-force-yBLyErpS.js +1 -0
- package/web/dist/assets/graph-link-OCo_s0zO.js +1 -0
- package/web/dist/assets/graph-view-sM1OlByf.js +3 -0
- package/web/dist/assets/index-CDcnUacl.js +10 -0
- package/web/dist/assets/index-OzJqnJWG.css +2 -0
- package/web/dist/assets/key-round-Cv_7QeED.js +1 -0
- package/web/dist/assets/loader-circle-BK_GMdvN.js +1 -0
- package/web/dist/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/web/dist/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-BbJatbhp.woff +0 -0
- package/web/dist/assets/orbitron-latin-900-normal-DrIi7unX.woff2 +0 -0
- package/web/dist/assets/query-builder-view-ChNFb51g.js +4 -0
- package/web/dist/assets/rotate-ccw-CdCaf7Q6.js +1 -0
- package/web/dist/assets/route-DO6IZ7of.js +15 -0
- package/web/dist/assets/seed-view-DFNB8WHE.js +7 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-15MgdvEM.woff +0 -0
- package/web/dist/assets/share-tech-mono-latin-400-normal-CCqdbj5z.woff2 +0 -0
- package/web/dist/assets/shield-alert-CMS-3j4U.js +1 -0
- package/web/dist/assets/sliders-horizontal-Dg51yc-C.js +1 -0
- package/web/dist/assets/sql-tabs-B4jt6bQj.js +2 -0
- package/web/dist/assets/table-DfMz2f8M.js +1 -0
- package/web/dist/assets/triangle-alert-COGnHCD1.js +1 -0
- package/web/dist/assets/zone-D9dXk8tn.js +5 -0
- package/web/dist/index.html +26 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
const flag = (value) => value === 't';
|
|
2
|
+
const commands = {
|
|
3
|
+
'*': 'ALL',
|
|
4
|
+
r: 'SELECT',
|
|
5
|
+
a: 'INSERT',
|
|
6
|
+
w: 'UPDATE',
|
|
7
|
+
d: 'DELETE',
|
|
8
|
+
};
|
|
9
|
+
/** Roles of the server that skip row level security: superusers and those with BYPASSRLS. */
|
|
10
|
+
async function bypassRoles(session) {
|
|
11
|
+
const rows = await session.rows(`SELECT rolname AS name FROM pg_catalog.pg_roles
|
|
12
|
+
WHERE (rolsuper OR rolbypassrls) AND rolname !~ '^pg_' ORDER BY rolname`);
|
|
13
|
+
return rows.map((row) => row.name ?? '');
|
|
14
|
+
}
|
|
15
|
+
const relationKinds = {
|
|
16
|
+
r: 'table',
|
|
17
|
+
p: 'partitioned',
|
|
18
|
+
v: 'view',
|
|
19
|
+
m: 'materialized',
|
|
20
|
+
f: 'foreign',
|
|
21
|
+
};
|
|
22
|
+
/** RLS state, policies and grants of a table or view; null when there is no such relation. */
|
|
23
|
+
export async function describeSecurity(session, table) {
|
|
24
|
+
const [relation] = await session.rows(`SELECT c.oid::text AS oid, c.relkind AS kind, pg_catalog.pg_get_userbyid(c.relowner) AS owner,
|
|
25
|
+
c.relrowsecurity AS enabled, c.relforcerowsecurity AS forced,
|
|
26
|
+
coalesce(c.reloptions::text[] && ARRAY['security_invoker=true', 'security_invoker=on',
|
|
27
|
+
'security_invoker=1', 'security_invoker=yes'], false) AS invoker
|
|
28
|
+
FROM pg_catalog.pg_class c
|
|
29
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
30
|
+
WHERE n.nspname = $1 AND c.relname = $2 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')`, [table.schema, table.name]);
|
|
31
|
+
if (!relation?.oid)
|
|
32
|
+
return null;
|
|
33
|
+
const policies = await session.rows(`SELECT p.polname AS name, p.polcmd AS command, p.polpermissive AS permissive,
|
|
34
|
+
(SELECT coalesce(json_agg(CASE WHEN r = 0 THEN 'public'
|
|
35
|
+
ELSE pg_catalog.pg_get_userbyid(r) END), '[]')::text
|
|
36
|
+
FROM unnest(p.polroles) AS r) AS roles,
|
|
37
|
+
pg_catalog.pg_get_expr(p.polqual, p.polrelid) AS qual,
|
|
38
|
+
pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid) AS with_check
|
|
39
|
+
FROM pg_catalog.pg_policy p
|
|
40
|
+
WHERE p.polrelid = $1::oid
|
|
41
|
+
ORDER BY p.polname`, [relation.oid]);
|
|
42
|
+
const grants = await session.rows(`SELECT CASE WHEN a.grantee = 0 THEN 'public' ELSE pg_catalog.pg_get_userbyid(a.grantee) END
|
|
43
|
+
AS grantee,
|
|
44
|
+
json_agg(a.privilege_type ORDER BY a.privilege_type)::text AS privileges
|
|
45
|
+
FROM pg_catalog.pg_class c,
|
|
46
|
+
LATERAL aclexplode(coalesce(c.relacl, acldefault('r', c.relowner))) AS a
|
|
47
|
+
WHERE c.oid = $1::oid
|
|
48
|
+
GROUP BY a.grantee
|
|
49
|
+
ORDER BY a.grantee = 0 DESC, 1`, [relation.oid]);
|
|
50
|
+
const kind = relationKinds[relation.kind ?? ''] ?? 'table';
|
|
51
|
+
const sources = kind === 'view' || kind === 'materialized'
|
|
52
|
+
? await session.rows(`SELECT DISTINCT n.nspname AS schema, t.relname AS name,
|
|
53
|
+
pg_catalog.pg_get_userbyid(t.relowner) AS owner,
|
|
54
|
+
t.relrowsecurity AS enabled, t.relforcerowsecurity AS forced,
|
|
55
|
+
(SELECT count(*) FROM pg_catalog.pg_policy p WHERE p.polrelid = t.oid) AS policies
|
|
56
|
+
FROM pg_catalog.pg_rewrite rw
|
|
57
|
+
JOIN pg_catalog.pg_depend d ON d.classid = 'pg_catalog.pg_rewrite'::regclass
|
|
58
|
+
AND d.objid = rw.oid AND d.refclassid = 'pg_catalog.pg_class'::regclass
|
|
59
|
+
JOIN pg_catalog.pg_class t ON t.oid = d.refobjid AND t.relkind IN ('r', 'p')
|
|
60
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
|
|
61
|
+
WHERE rw.ev_class = $1::oid AND t.oid <> rw.ev_class
|
|
62
|
+
ORDER BY 1, 2`, [relation.oid])
|
|
63
|
+
: [];
|
|
64
|
+
return {
|
|
65
|
+
kind,
|
|
66
|
+
owner: relation.owner ?? '',
|
|
67
|
+
securityInvoker: flag(relation.invoker),
|
|
68
|
+
sources: sources.map((row) => ({
|
|
69
|
+
schema: row.schema ?? '',
|
|
70
|
+
name: row.name ?? '',
|
|
71
|
+
owner: row.owner ?? '',
|
|
72
|
+
rlsEnabled: flag(row.enabled),
|
|
73
|
+
rlsForced: flag(row.forced),
|
|
74
|
+
policies: Number(row.policies),
|
|
75
|
+
})),
|
|
76
|
+
rlsEnabled: flag(relation.enabled),
|
|
77
|
+
rlsForced: flag(relation.forced),
|
|
78
|
+
policies: policies.map((row) => ({
|
|
79
|
+
name: row.name ?? '',
|
|
80
|
+
command: commands[row.command ?? ''] ?? 'ALL',
|
|
81
|
+
permissive: flag(row.permissive),
|
|
82
|
+
roles: JSON.parse(row.roles ?? '[]'),
|
|
83
|
+
using: row.qual,
|
|
84
|
+
withCheck: row.with_check,
|
|
85
|
+
})),
|
|
86
|
+
grants: grants.map((row) => ({
|
|
87
|
+
grantee: row.grantee ?? '',
|
|
88
|
+
privileges: JSON.parse(row.privileges ?? '[]'),
|
|
89
|
+
})),
|
|
90
|
+
bypassRoles: await bypassRoles(session).catch(() => []),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Who a function runs as and may be run by; null when there is no such function. */
|
|
94
|
+
export async function describeFunctionSecurity(session, ref) {
|
|
95
|
+
const [fn] = await session.rows(`SELECT p.oid::text AS oid, pg_catalog.pg_get_userbyid(p.proowner) AS owner,
|
|
96
|
+
p.prosecdef AS definer, p.proleakproof AS leakproof,
|
|
97
|
+
to_json(coalesce(p.proconfig, '{}'::text[]))::text AS config
|
|
98
|
+
FROM pg_catalog.pg_proc p
|
|
99
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
100
|
+
WHERE n.nspname = $1 AND p.proname = $2
|
|
101
|
+
AND pg_catalog.pg_get_function_identity_arguments(p.oid) = $3`, [ref.schema, ref.name, ref.args]);
|
|
102
|
+
if (!fn?.oid)
|
|
103
|
+
return null;
|
|
104
|
+
const grants = await session.rows(`SELECT CASE WHEN a.grantee = 0 THEN 'public' ELSE pg_catalog.pg_get_userbyid(a.grantee) END
|
|
105
|
+
AS grantee,
|
|
106
|
+
json_agg(a.privilege_type ORDER BY a.privilege_type)::text AS privileges
|
|
107
|
+
FROM pg_catalog.pg_proc p,
|
|
108
|
+
LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) AS a
|
|
109
|
+
WHERE p.oid = $1::oid
|
|
110
|
+
GROUP BY a.grantee
|
|
111
|
+
ORDER BY a.grantee = 0 DESC, 1`, [fn.oid]);
|
|
112
|
+
return {
|
|
113
|
+
owner: fn.owner ?? '',
|
|
114
|
+
securityDefiner: flag(fn.definer),
|
|
115
|
+
leakproof: flag(fn.leakproof),
|
|
116
|
+
config: JSON.parse(fn.config ?? '[]'),
|
|
117
|
+
grants: grants.map((row) => ({
|
|
118
|
+
grantee: row.grantee ?? '',
|
|
119
|
+
privileges: JSON.parse(row.privileges ?? '[]'),
|
|
120
|
+
})),
|
|
121
|
+
bypassRoles: await bypassRoles(session).catch(() => []),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** The roles of the server (not the built in pg_ ones) and how each stands against RLS. */
|
|
125
|
+
export async function listRoles(session) {
|
|
126
|
+
const rows = await session.rows(`SELECT r.rolname AS name, r.rolsuper AS superuser, r.rolcanlogin AS can_login,
|
|
127
|
+
r.rolcreaterole AS create_role, r.rolcreatedb AS create_db,
|
|
128
|
+
r.rolreplication AS replication, r.rolbypassrls AS bypass_rls,
|
|
129
|
+
r.rolconnlimit AS connection_limit,
|
|
130
|
+
(SELECT count(*) FROM pg_catalog.pg_policy p WHERE r.oid = ANY (p.polroles)) AS policies,
|
|
131
|
+
(SELECT count(*) FROM pg_catalog.pg_class c
|
|
132
|
+
WHERE c.relowner = r.oid AND c.relkind IN ('r', 'p') AND c.relrowsecurity) AS owned_rls,
|
|
133
|
+
(SELECT count(*) FROM pg_catalog.pg_class c
|
|
134
|
+
WHERE c.relowner = r.oid AND c.relkind IN ('r', 'p') AND c.relforcerowsecurity)
|
|
135
|
+
AS owned_forced
|
|
136
|
+
FROM pg_catalog.pg_roles r
|
|
137
|
+
WHERE r.rolname !~ '^pg_'
|
|
138
|
+
ORDER BY r.rolsuper DESC, r.rolname`);
|
|
139
|
+
return rows.map((row) => ({
|
|
140
|
+
name: row.name ?? '',
|
|
141
|
+
superuser: flag(row.superuser),
|
|
142
|
+
canLogin: flag(row.can_login),
|
|
143
|
+
createRole: flag(row.create_role),
|
|
144
|
+
createDb: flag(row.create_db),
|
|
145
|
+
replication: flag(row.replication),
|
|
146
|
+
bypassRls: flag(row.bypass_rls),
|
|
147
|
+
connectionLimit: Number(row.connection_limit),
|
|
148
|
+
policies: Number(row.policies),
|
|
149
|
+
ownedRlsTables: Number(row.owned_rls),
|
|
150
|
+
ownedForcedTables: Number(row.owned_forced),
|
|
151
|
+
}));
|
|
152
|
+
}
|