@kazurolabs/psql-explorer 0.1.0 → 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/README.md +229 -18
- package/RELEASES.md +87 -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 +24 -0
- 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 +22 -8
- 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 +17 -0
- 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 +654 -21
- package/package.json +3 -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-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/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/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/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 +9 -2
- package/web/dist/assets/index-BMLFCtFY.js +0 -17
- package/web/dist/assets/index-CMXJRL9O.css +0 -2
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// Turns the difference between a table as it is and as it was edited into ALTER statements. Pure:
|
|
2
|
+
// rows of the two designs are matched by id, so a renamed column is a RENAME and not a drop and an
|
|
3
|
+
// add. Each statement carries what the page needs to warn about it and to offer a safer way.
|
|
4
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
5
|
+
import { columnSql, constraintSql, DdlError, indexSql, literal, suggestConstraintName, typeSql, } from './ddl.js';
|
|
6
|
+
export class AlterError extends DdlError {
|
|
7
|
+
}
|
|
8
|
+
/** A table with more rows than this gets a warning where a statement has to read all of them. */
|
|
9
|
+
export const BIG_TABLE_ROWS = 10_000;
|
|
10
|
+
const same = (a, b) => (a ?? '').trim() === (b ?? '').trim();
|
|
11
|
+
function rowId(kind, row) {
|
|
12
|
+
const id = row.id ?? (row.name ? `name:${row.name}` : undefined);
|
|
13
|
+
if (id === undefined)
|
|
14
|
+
throw new AlterError(`A ${kind} has neither an id nor a name`);
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
const rows = (estimate) => estimate === null || estimate === undefined
|
|
18
|
+
? 'a table never analyzed, so of unknown size'
|
|
19
|
+
: `about ${estimate.toLocaleString('en-US')} rows`;
|
|
20
|
+
const big = (estimate) => estimate === null || estimate === undefined || estimate >= BIG_TABLE_ROWS;
|
|
21
|
+
const qualified = (table) => `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.name)}`;
|
|
22
|
+
/** Whether changing a column from one type to the other has to rewrite every row. */
|
|
23
|
+
export function rewritesTable(from, to) {
|
|
24
|
+
if (typeSql(from) === typeSql(to))
|
|
25
|
+
return false;
|
|
26
|
+
if (from.schema !== undefined || to.schema !== undefined)
|
|
27
|
+
return true;
|
|
28
|
+
if ((from.array ?? 0) !== (to.array ?? 0))
|
|
29
|
+
return true;
|
|
30
|
+
// A longer or no limit on varchar, and varchar to text, leave the stored values as they are.
|
|
31
|
+
if (from.name === 'varchar' && to.name === 'text')
|
|
32
|
+
return false;
|
|
33
|
+
if (from.name === 'varchar' && to.name === 'varchar') {
|
|
34
|
+
return to.length !== undefined && (from.length === undefined || to.length < from.length);
|
|
35
|
+
}
|
|
36
|
+
if (from.name === 'numeric' && to.name === 'numeric') {
|
|
37
|
+
const wider = from.precision !== undefined &&
|
|
38
|
+
(to.precision === undefined ||
|
|
39
|
+
(to.precision >= from.precision && (to.scale ?? 0) === (from.scale ?? 0)));
|
|
40
|
+
return !wider && !(from.precision === undefined && to.precision === undefined);
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
function resolve(design) {
|
|
45
|
+
const columns = new Map();
|
|
46
|
+
const names = new Map();
|
|
47
|
+
for (const column of design.columns) {
|
|
48
|
+
const id = rowId('column', column);
|
|
49
|
+
if (columns.has(id))
|
|
50
|
+
throw new AlterError(`Two columns share the id "${id}"`);
|
|
51
|
+
columns.set(id, column);
|
|
52
|
+
names.set(column.name, id);
|
|
53
|
+
}
|
|
54
|
+
return { columns, names };
|
|
55
|
+
}
|
|
56
|
+
/** A constraint's meaning with columns as ids, so a rename of a column does not change it. */
|
|
57
|
+
function constraintShape(design, resolved, constraint) {
|
|
58
|
+
const ids = (columns) => columns.map((name) => resolved.names.get(name) ?? name);
|
|
59
|
+
switch (constraint.kind) {
|
|
60
|
+
case 'primary':
|
|
61
|
+
return { kind: 'primary', columns: ids(constraint.columns) };
|
|
62
|
+
case 'unique':
|
|
63
|
+
return {
|
|
64
|
+
kind: 'unique',
|
|
65
|
+
columns: ids(constraint.columns),
|
|
66
|
+
nulls: !!constraint.nullsNotDistinct,
|
|
67
|
+
};
|
|
68
|
+
case 'check':
|
|
69
|
+
return { kind: 'check', expression: constraint.expression.trim() };
|
|
70
|
+
case 'foreign': {
|
|
71
|
+
const target = constraint.references;
|
|
72
|
+
const self = target.schema === design.schema && target.table === design.name;
|
|
73
|
+
return {
|
|
74
|
+
kind: 'foreign',
|
|
75
|
+
columns: ids(constraint.columns),
|
|
76
|
+
target: self ? 'self' : `${target.schema}.${target.table}`,
|
|
77
|
+
targetColumns: self ? ids(target.columns) : target.columns,
|
|
78
|
+
onDelete: constraint.onDelete ?? 'no action',
|
|
79
|
+
onUpdate: constraint.onUpdate ?? 'no action',
|
|
80
|
+
full: !!constraint.matchFull,
|
|
81
|
+
deferrable: !!constraint.deferrable,
|
|
82
|
+
deferred: !!constraint.initiallyDeferred,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function indexShape(resolved, index) {
|
|
88
|
+
return {
|
|
89
|
+
unique: !!index.unique,
|
|
90
|
+
method: index.method ?? 'btree',
|
|
91
|
+
items: index.items.map((item) => [
|
|
92
|
+
'column' in item ? (resolved.names.get(item.column) ?? item.column) : item.expression.trim(),
|
|
93
|
+
'column' in item,
|
|
94
|
+
!!item.descending,
|
|
95
|
+
]),
|
|
96
|
+
include: (index.include ?? []).map((name) => resolved.names.get(name) ?? name),
|
|
97
|
+
where: (index.where ?? '').trim(),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const shapeKey = (shape) => JSON.stringify(shape);
|
|
101
|
+
function checkUnique(what, ids) {
|
|
102
|
+
if (new Set(ids).size !== ids.length)
|
|
103
|
+
throw new AlterError(`Two ${what}s share an id`);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The statements that change `before` into `after`, in an order that works: what goes first, then
|
|
107
|
+
* columns, constraints and indexes, then the table itself (so every statement but the last names the
|
|
108
|
+
* table as it is now). The first statement sets `lock_timeout`; the caller adds it.
|
|
109
|
+
*/
|
|
110
|
+
export function diffDesigns(before, after, options = {}) {
|
|
111
|
+
const old = resolve(before);
|
|
112
|
+
const next = resolve(after);
|
|
113
|
+
if (!after.schema)
|
|
114
|
+
throw new AlterError('The table needs a schema');
|
|
115
|
+
if (!after.name)
|
|
116
|
+
throw new AlterError('The table needs a name');
|
|
117
|
+
if (after.columns.length === 0)
|
|
118
|
+
throw new AlterError('The table needs a column');
|
|
119
|
+
for (const column of after.columns) {
|
|
120
|
+
if (!column.name)
|
|
121
|
+
throw new AlterError('A column needs a name');
|
|
122
|
+
}
|
|
123
|
+
const partitionId = (design, resolved) => design.partition ? resolved.names.get(design.partition.column) : undefined;
|
|
124
|
+
if (before.partition?.method !== after.partition?.method ||
|
|
125
|
+
partitionId(before, old) !== partitionId(after, next)) {
|
|
126
|
+
throw new AlterError('How a table is partitioned cannot be changed: make a new table');
|
|
127
|
+
}
|
|
128
|
+
const serverMajor = options.serverMajor ?? 99;
|
|
129
|
+
const table = qualified(before);
|
|
130
|
+
const estimate = options.estimatedRows;
|
|
131
|
+
const out = [];
|
|
132
|
+
const chosen = (list, key) => !!list?.includes(key);
|
|
133
|
+
const add = (statement) => out.push({ warnings: [], offers: [], ...statement });
|
|
134
|
+
const afterConstraints = (after.constraints ?? []).map((c) => ({
|
|
135
|
+
row: c,
|
|
136
|
+
id: rowId('constraint', c),
|
|
137
|
+
}));
|
|
138
|
+
const beforeConstraints = (before.constraints ?? []).map((c) => ({
|
|
139
|
+
row: c,
|
|
140
|
+
id: rowId('constraint', c),
|
|
141
|
+
}));
|
|
142
|
+
const afterIndexes = (after.indexes ?? []).map((i) => ({ row: i, id: rowId('index', i) }));
|
|
143
|
+
const beforeIndexes = (before.indexes ?? []).map((i) => ({ row: i, id: rowId('index', i) }));
|
|
144
|
+
checkUnique('constraint', afterConstraints.map((c) => c.id));
|
|
145
|
+
checkUnique('index', afterIndexes.map((i) => i.id));
|
|
146
|
+
// --- What changed, constraints and indexes
|
|
147
|
+
const dropConstraints = [];
|
|
148
|
+
const addConstraints = [];
|
|
149
|
+
const renameConstraints = [];
|
|
150
|
+
for (const was of beforeConstraints) {
|
|
151
|
+
const is = afterConstraints.find((c) => c.id === was.id);
|
|
152
|
+
if (!is) {
|
|
153
|
+
dropConstraints.push(was);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const wasShape = shapeKey(constraintShape(before, old, was.row));
|
|
157
|
+
const isShape = shapeKey(constraintShape(after, next, is.row));
|
|
158
|
+
if (wasShape !== isShape) {
|
|
159
|
+
dropConstraints.push(was);
|
|
160
|
+
addConstraints.push(is);
|
|
161
|
+
}
|
|
162
|
+
else if (is.row.name && was.row.name !== is.row.name) {
|
|
163
|
+
renameConstraints.push({ from: was.row.name ?? '', to: is.row.name, id: was.id });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const is of afterConstraints) {
|
|
167
|
+
if (!beforeConstraints.some((c) => c.id === is.id))
|
|
168
|
+
addConstraints.push(is);
|
|
169
|
+
}
|
|
170
|
+
const dropIndexes = [];
|
|
171
|
+
const addIndexes = [];
|
|
172
|
+
const renameIndexes = [];
|
|
173
|
+
for (const was of beforeIndexes) {
|
|
174
|
+
const is = afterIndexes.find((i) => i.id === was.id);
|
|
175
|
+
if (!is) {
|
|
176
|
+
dropIndexes.push(was);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (shapeKey(indexShape(old, was.row)) !== shapeKey(indexShape(next, is.row))) {
|
|
180
|
+
dropIndexes.push(was);
|
|
181
|
+
addIndexes.push(is);
|
|
182
|
+
}
|
|
183
|
+
else if (is.row.name && was.row.name !== is.row.name) {
|
|
184
|
+
renameIndexes.push({ from: was.row.name ?? '', to: is.row.name, id: was.id });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
for (const is of afterIndexes) {
|
|
188
|
+
if (!beforeIndexes.some((i) => i.id === is.id))
|
|
189
|
+
addIndexes.push(is);
|
|
190
|
+
}
|
|
191
|
+
// --- 1. Drop constraints and indexes that go or change
|
|
192
|
+
for (const { row, id } of dropConstraints) {
|
|
193
|
+
if (!row.name)
|
|
194
|
+
throw new AlterError('A constraint to drop has no name');
|
|
195
|
+
const key = `drop-constraint:${id}`;
|
|
196
|
+
add({
|
|
197
|
+
key,
|
|
198
|
+
sql: `ALTER TABLE ${table} DROP CONSTRAINT ${quoteIdentifier(row.name)}${chosen(options.cascade, key) ? ' CASCADE' : ''}`,
|
|
199
|
+
drop: { kind: 'constraint', schema: before.schema, name: before.name, detail: row.name },
|
|
200
|
+
offers: ['cascade'],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
for (const { row, id } of dropIndexes) {
|
|
204
|
+
if (!row.name)
|
|
205
|
+
throw new AlterError('An index to drop has no name');
|
|
206
|
+
const key = `drop-index:${id}`;
|
|
207
|
+
add({
|
|
208
|
+
key,
|
|
209
|
+
sql: `DROP INDEX ${quoteIdentifier(before.schema)}.${quoteIdentifier(row.name)}${chosen(options.cascade, key) ? ' CASCADE' : ''}`,
|
|
210
|
+
drop: { kind: 'index', schema: before.schema, name: row.name },
|
|
211
|
+
offers: ['cascade'],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
// --- 2. Drop columns
|
|
215
|
+
for (const [id, column] of old.columns) {
|
|
216
|
+
if (next.columns.has(id))
|
|
217
|
+
continue;
|
|
218
|
+
const key = `drop-column:${id}`;
|
|
219
|
+
add({
|
|
220
|
+
key,
|
|
221
|
+
sql: `ALTER TABLE ${table} DROP COLUMN ${quoteIdentifier(column.name)}${chosen(options.cascade, key) ? ' CASCADE' : ''}`,
|
|
222
|
+
drop: { kind: 'column', schema: before.schema, name: before.name, detail: column.name },
|
|
223
|
+
offers: ['cascade'],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// --- 3. Rename columns
|
|
227
|
+
for (const [id, column] of old.columns) {
|
|
228
|
+
const edited = next.columns.get(id);
|
|
229
|
+
if (edited && edited.name !== column.name) {
|
|
230
|
+
add({
|
|
231
|
+
key: `rename-column:${id}`,
|
|
232
|
+
sql: `ALTER TABLE ${table} RENAME COLUMN ${quoteIdentifier(column.name)} TO ${quoteIdentifier(edited.name)}`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// --- 4. Add columns
|
|
237
|
+
const tableBefore = { schema: before.schema, name: before.name };
|
|
238
|
+
for (const [id, column] of next.columns) {
|
|
239
|
+
if (old.columns.has(id))
|
|
240
|
+
continue;
|
|
241
|
+
const warnings = [];
|
|
242
|
+
if (column.notNull &&
|
|
243
|
+
!column.default &&
|
|
244
|
+
!column.identity &&
|
|
245
|
+
!column.generated &&
|
|
246
|
+
big(estimate)) {
|
|
247
|
+
warnings.push(`NOT NULL without a default fails if the table has any rows (${rows(estimate)}).`);
|
|
248
|
+
}
|
|
249
|
+
if ((column.default || column.generated || column.identity) && big(estimate)) {
|
|
250
|
+
warnings.push(`A default that is not constant, or a generated column, rewrites the table (${rows(estimate)}) while it holds a lock.`);
|
|
251
|
+
}
|
|
252
|
+
add({
|
|
253
|
+
key: `add-column:${id}`,
|
|
254
|
+
sql: `ALTER TABLE ${table} ADD COLUMN ${columnSql(column)}`,
|
|
255
|
+
warnings,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
// --- 5. Change columns that stay
|
|
259
|
+
for (const [id, was] of old.columns) {
|
|
260
|
+
const is = next.columns.get(id);
|
|
261
|
+
if (!is)
|
|
262
|
+
continue;
|
|
263
|
+
const column = `ALTER TABLE ${table} ALTER COLUMN ${quoteIdentifier(is.name)}`;
|
|
264
|
+
const wasGenerated = was.generated !== undefined;
|
|
265
|
+
const isGenerated = is.generated !== undefined;
|
|
266
|
+
if (!wasGenerated && isGenerated) {
|
|
267
|
+
throw new AlterError(`"${is.name}" cannot become a generated column in place: add a new column and drop this one`);
|
|
268
|
+
}
|
|
269
|
+
if (was.identity && !is.identity) {
|
|
270
|
+
add({ key: `identity:${id}`, sql: `${column} DROP IDENTITY` });
|
|
271
|
+
}
|
|
272
|
+
if (wasGenerated && !isGenerated) {
|
|
273
|
+
add({ key: `generated:${id}`, sql: `${column} DROP EXPRESSION` });
|
|
274
|
+
}
|
|
275
|
+
else if (wasGenerated && isGenerated && !same(was.generated, is.generated)) {
|
|
276
|
+
if (serverMajor < 17) {
|
|
277
|
+
throw new AlterError(`Changing the expression of "${is.name}" needs PostgreSQL 17 or later`);
|
|
278
|
+
}
|
|
279
|
+
add({
|
|
280
|
+
key: `generated:${id}`,
|
|
281
|
+
sql: `${column} SET EXPRESSION AS (${is.generated.trim()})`,
|
|
282
|
+
warnings: big(estimate) ? [`Recomputes every row (${rows(estimate)}).`] : [],
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (typeSql(was.type) !== typeSql(is.type)) {
|
|
286
|
+
const using = options.using?.[id]?.trim();
|
|
287
|
+
add({
|
|
288
|
+
key: `type:${id}`,
|
|
289
|
+
sql: `${column} TYPE ${typeSql(is.type)}${using ? ` USING ${using}` : ''}`,
|
|
290
|
+
warnings: rewritesTable(was.type, is.type)
|
|
291
|
+
? [
|
|
292
|
+
`Changing the type rewrites the table (${rows(estimate)}), holding a lock that blocks reads and writes meanwhile.`,
|
|
293
|
+
]
|
|
294
|
+
: [],
|
|
295
|
+
offers: ['using'],
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
if (!isGenerated && !is.identity && !same(was.default, is.default)) {
|
|
299
|
+
add({
|
|
300
|
+
key: `default:${id}`,
|
|
301
|
+
sql: is.default?.trim()
|
|
302
|
+
? `${column} SET DEFAULT ${is.default.trim()}`
|
|
303
|
+
: `${column} DROP DEFAULT`,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
if (!!was.notNull !== !!is.notNull) {
|
|
307
|
+
add({
|
|
308
|
+
key: `not-null:${id}`,
|
|
309
|
+
sql: `${column} ${is.notNull ? 'SET' : 'DROP'} NOT NULL`,
|
|
310
|
+
warnings: is.notNull && big(estimate)
|
|
311
|
+
? [`Checks every row for nulls (${rows(estimate)}) while holding a lock.`]
|
|
312
|
+
: [],
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (is.identity && was.identity !== is.identity) {
|
|
316
|
+
const kind = is.identity === 'always' ? 'ALWAYS' : 'BY DEFAULT';
|
|
317
|
+
add({
|
|
318
|
+
key: `identity:${id}`,
|
|
319
|
+
sql: was.identity
|
|
320
|
+
? `${column} SET GENERATED ${kind}`
|
|
321
|
+
: `${column} ADD GENERATED ${kind} AS IDENTITY`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// --- 6. Rename constraints and indexes that otherwise stay
|
|
326
|
+
for (const { from, to, id } of renameConstraints) {
|
|
327
|
+
add({
|
|
328
|
+
key: `rename-constraint:${id}`,
|
|
329
|
+
sql: `ALTER TABLE ${table} RENAME CONSTRAINT ${quoteIdentifier(from)} TO ${quoteIdentifier(to)}`,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
for (const { from, to, id } of renameIndexes) {
|
|
333
|
+
add({
|
|
334
|
+
key: `rename-index:${id}`,
|
|
335
|
+
sql: `ALTER INDEX ${quoteIdentifier(before.schema)}.${quoteIdentifier(from)} RENAME TO ${quoteIdentifier(to)}`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
// --- 7. Add constraints
|
|
339
|
+
const stub = { ...after, ...tableBefore };
|
|
340
|
+
const columnNames = new Set(after.columns.map((column) => column.name));
|
|
341
|
+
for (const { row, id } of addConstraints) {
|
|
342
|
+
const key = `add-constraint:${id}`;
|
|
343
|
+
const checked = row.kind === 'foreign' || row.kind === 'check';
|
|
344
|
+
const notValid = checked && chosen(options.notValid, key);
|
|
345
|
+
// The table is renamed last, so a key to itself still names the table as it is now.
|
|
346
|
+
const aimed = row.kind === 'foreign' &&
|
|
347
|
+
row.references.schema === after.schema &&
|
|
348
|
+
row.references.table === after.name
|
|
349
|
+
? {
|
|
350
|
+
...row,
|
|
351
|
+
references: {
|
|
352
|
+
...row.references,
|
|
353
|
+
...tableBefore,
|
|
354
|
+
table: before.name,
|
|
355
|
+
schema: before.schema,
|
|
356
|
+
},
|
|
357
|
+
}
|
|
358
|
+
: row;
|
|
359
|
+
const name = notValid ? (row.name ?? suggestConstraintName(before.name, row)) : row.name;
|
|
360
|
+
const withName = name === undefined ? aimed : { ...aimed, name };
|
|
361
|
+
const sql = `ALTER TABLE ${table} ADD ${constraintSql(withName, { serverMajor: options.serverMajor }, columnNames)}${notValid ? ' NOT VALID' : ''}`;
|
|
362
|
+
const warnings = [];
|
|
363
|
+
const offers = [];
|
|
364
|
+
if (checked) {
|
|
365
|
+
if (big(estimate) && !notValid) {
|
|
366
|
+
warnings.push(`Checks every existing row (${rows(estimate)}) while holding a lock: it can be added NOT VALID and validated afterwards.`);
|
|
367
|
+
}
|
|
368
|
+
offers.push('notValid');
|
|
369
|
+
}
|
|
370
|
+
else if (big(estimate)) {
|
|
371
|
+
warnings.push(`Builds an index over the table (${rows(estimate)}), blocking writes meanwhile.`);
|
|
372
|
+
}
|
|
373
|
+
add({ key, sql, warnings, offers });
|
|
374
|
+
if (notValid) {
|
|
375
|
+
add({
|
|
376
|
+
key: `${key}:validate`,
|
|
377
|
+
sql: `ALTER TABLE ${table} VALIDATE CONSTRAINT ${quoteIdentifier(name)}`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// --- 8. Create indexes
|
|
382
|
+
for (const { row, id } of addIndexes) {
|
|
383
|
+
const key = `add-index:${id}`;
|
|
384
|
+
const concurrent = !after.partition && chosen(options.concurrently, key);
|
|
385
|
+
let sql = indexSql(stub, row, columnNames);
|
|
386
|
+
if (concurrent)
|
|
387
|
+
sql = sql.replace(/^CREATE (UNIQUE )?INDEX /, 'CREATE $1INDEX CONCURRENTLY ');
|
|
388
|
+
add({
|
|
389
|
+
key,
|
|
390
|
+
sql,
|
|
391
|
+
concurrent,
|
|
392
|
+
warnings: big(estimate) && !concurrent
|
|
393
|
+
? [`Builds an index over the table (${rows(estimate)}), blocking writes meanwhile.`]
|
|
394
|
+
: [],
|
|
395
|
+
offers: after.partition ? [] : ['concurrently'],
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
// --- 9. Comments
|
|
399
|
+
const comment = (target, text) => `COMMENT ON ${target} IS ${text ? literal(text) : 'NULL'}`;
|
|
400
|
+
if (!same(before.comment, after.comment)) {
|
|
401
|
+
add({ key: 'comment:table', sql: comment(`TABLE ${table}`, after.comment) });
|
|
402
|
+
}
|
|
403
|
+
for (const [id, is] of next.columns) {
|
|
404
|
+
const was = old.columns.get(id);
|
|
405
|
+
if (!same(was?.comment, is.comment)) {
|
|
406
|
+
add({
|
|
407
|
+
key: `comment:${id}`,
|
|
408
|
+
sql: comment(`COLUMN ${table}.${quoteIdentifier(is.name)}`, is.comment),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// --- 10. The table itself
|
|
413
|
+
if (!!before.unlogged !== !!after.unlogged) {
|
|
414
|
+
add({
|
|
415
|
+
key: 'logged',
|
|
416
|
+
sql: `ALTER TABLE ${table} SET ${after.unlogged ? 'UNLOGGED' : 'LOGGED'}`,
|
|
417
|
+
warnings: [`Rewrites the table (${rows(estimate)}) while holding a lock.`],
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
let current = { ...tableBefore };
|
|
421
|
+
if (after.schema !== before.schema) {
|
|
422
|
+
add({
|
|
423
|
+
key: 'schema',
|
|
424
|
+
sql: `ALTER TABLE ${qualified(current)} SET SCHEMA ${quoteIdentifier(after.schema)}`,
|
|
425
|
+
warnings: ['Code and views that name the table by its old schema stop finding it.'],
|
|
426
|
+
});
|
|
427
|
+
current = { schema: after.schema, name: current.name };
|
|
428
|
+
}
|
|
429
|
+
if (after.name !== before.name) {
|
|
430
|
+
add({
|
|
431
|
+
key: 'rename',
|
|
432
|
+
sql: `ALTER TABLE ${qualified(current)} RENAME TO ${quoteIdentifier(after.name)}`,
|
|
433
|
+
warnings: ['Code and views that use the old name stop finding the table.'],
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
/** `DROP TABLE`, as a statement of its own. */
|
|
439
|
+
export function dropTableStatement(table, cascade) {
|
|
440
|
+
return {
|
|
441
|
+
key: 'drop-table',
|
|
442
|
+
sql: `DROP TABLE ${qualified(table)}${cascade ? ' CASCADE' : ''}`,
|
|
443
|
+
drop: { kind: 'table', schema: table.schema, name: table.name },
|
|
444
|
+
warnings: [],
|
|
445
|
+
offers: ['cascade'],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
/** The statements that run in the transaction, with `lock_timeout` first, and those that run apart. */
|
|
449
|
+
export function splitPlan(statements, lockTimeoutMs) {
|
|
450
|
+
if (!Number.isInteger(lockTimeoutMs) || lockTimeoutMs < 0) {
|
|
451
|
+
throw new AlterError('The lock timeout is a number of milliseconds');
|
|
452
|
+
}
|
|
453
|
+
const transactional = statements.filter((statement) => !statement.concurrent).map((s) => s.sql);
|
|
454
|
+
return {
|
|
455
|
+
transactional: transactional.length > 0
|
|
456
|
+
? [`SET LOCAL lock_timeout = '${lockTimeoutMs}ms'`, ...transactional]
|
|
457
|
+
: [],
|
|
458
|
+
concurrent: statements.filter((statement) => statement.concurrent).map((s) => s.sql),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
const stringList = (value, what) => {
|
|
462
|
+
if (value === undefined || value === null)
|
|
463
|
+
return [];
|
|
464
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
465
|
+
throw new AlterError(`${what} must be a list of text`);
|
|
466
|
+
}
|
|
467
|
+
return value;
|
|
468
|
+
};
|
|
469
|
+
/** Reads the options sent with an edit, refusing anything that is not what they should be. */
|
|
470
|
+
export function parseAlterOptions(value) {
|
|
471
|
+
if (value === undefined || value === null)
|
|
472
|
+
return {};
|
|
473
|
+
if (typeof value !== 'object' || Array.isArray(value))
|
|
474
|
+
throw new AlterError('options must be an object');
|
|
475
|
+
const raw = value;
|
|
476
|
+
const using = {};
|
|
477
|
+
if (raw.using !== undefined && raw.using !== null) {
|
|
478
|
+
if (typeof raw.using !== 'object' || Array.isArray(raw.using)) {
|
|
479
|
+
throw new AlterError('using must be an object');
|
|
480
|
+
}
|
|
481
|
+
for (const [id, text] of Object.entries(raw.using)) {
|
|
482
|
+
if (typeof text !== 'string')
|
|
483
|
+
throw new AlterError('A USING expression must be text');
|
|
484
|
+
using[id] = text;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
using,
|
|
489
|
+
notValid: stringList(raw.notValid, 'notValid'),
|
|
490
|
+
concurrently: stringList(raw.concurrently, 'concurrently'),
|
|
491
|
+
cascade: stringList(raw.cascade, 'cascade'),
|
|
492
|
+
};
|
|
493
|
+
}
|
package/dist/explorer/catalog.js
CHANGED
|
@@ -62,9 +62,14 @@ export async function listTables(session, schema, name) {
|
|
|
62
62
|
ELSE c.reltuples::int8 END AS estimated_rows,
|
|
63
63
|
CASE WHEN c.relkind IN ('r', 'p', 'm') THEN pg_catalog.pg_total_relation_size(c.oid)
|
|
64
64
|
END AS total_bytes,
|
|
65
|
+
to_char(greatest(s.last_vacuum, s.last_autovacuum) AT TIME ZONE 'UTC',
|
|
66
|
+
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS last_vacuum,
|
|
67
|
+
to_char(greatest(s.last_analyze, s.last_autoanalyze) AT TIME ZONE 'UTC',
|
|
68
|
+
'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS last_analyze,
|
|
65
69
|
pg_catalog.obj_description(c.oid, 'pg_class') AS comment
|
|
66
70
|
FROM pg_catalog.pg_class c
|
|
67
71
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
72
|
+
LEFT JOIN pg_catalog.pg_stat_all_tables s ON s.relid = c.oid
|
|
68
73
|
WHERE n.nspname = $1 AND c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
|
69
74
|
AND ($2::text IS NULL OR c.relname = $2)
|
|
70
75
|
ORDER BY c.relname`, [schema, name ?? null]);
|
|
@@ -74,6 +79,8 @@ export async function listTables(session, schema, name) {
|
|
|
74
79
|
kind: kinds[row.kind ?? ''] ?? 'table',
|
|
75
80
|
estimatedRows: count(row.estimated_rows),
|
|
76
81
|
totalBytes: count(row.total_bytes),
|
|
82
|
+
lastVacuum: row.last_vacuum,
|
|
83
|
+
lastAnalyze: row.last_analyze,
|
|
77
84
|
comment: row.comment,
|
|
78
85
|
}));
|
|
79
86
|
}
|
|
@@ -233,6 +240,30 @@ export async function describeCatalog(session) {
|
|
|
233
240
|
}
|
|
234
241
|
return catalog;
|
|
235
242
|
}
|
|
243
|
+
/** Every function of every user schema with its declared arguments, for explaining calls in SQL. */
|
|
244
|
+
export async function describeFunctionCatalog(session) {
|
|
245
|
+
const rows = await session.rows(`SELECT n.nspname AS schema, p.proname AS name,
|
|
246
|
+
pg_catalog.pg_get_function_identity_arguments(p.oid) AS args,
|
|
247
|
+
pg_catalog.pg_get_function_arguments(p.oid) AS signature, p.prokind AS kind,
|
|
248
|
+
CASE WHEN p.prokind = 'p' THEN NULL ELSE pg_catalog.pg_get_function_result(p.oid) END AS returns,
|
|
249
|
+
l.lanname AS language, pg_catalog.obj_description(p.oid, 'pg_proc') AS comment
|
|
250
|
+
FROM pg_catalog.pg_proc p
|
|
251
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace
|
|
252
|
+
JOIN pg_catalog.pg_language l ON l.oid = p.prolang
|
|
253
|
+
WHERE n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'
|
|
254
|
+
OR n.nspname = 'pg_catalog' AND p.prokind IN ('f', 'a', 'w') AND p.proname !~ '^_'
|
|
255
|
+
ORDER BY n.nspname, p.proname, args`);
|
|
256
|
+
return rows.map((row) => ({
|
|
257
|
+
schema: row.schema ?? '',
|
|
258
|
+
name: row.name ?? '',
|
|
259
|
+
args: row.args ?? '',
|
|
260
|
+
signature: row.signature ?? '',
|
|
261
|
+
kind: functionKinds[row.kind ?? ''] ?? 'function',
|
|
262
|
+
returns: row.returns,
|
|
263
|
+
language: row.language ?? '',
|
|
264
|
+
comment: row.comment,
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
236
267
|
/** A table, view, ... with its columns, indexes and constraints. Null when there is no such one. */
|
|
237
268
|
export async function describeRelation(session, table) {
|
|
238
269
|
const [info] = await listTables(session, table.schema, table.name);
|
|
@@ -5,27 +5,38 @@ import { resolveEntry } from '../environments.js';
|
|
|
5
5
|
export class LoginError extends Error {
|
|
6
6
|
name = 'LoginError';
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Looks up everything an environment needs (from Vault, a prompt, ...) once and opens up to `count`
|
|
10
|
+
* connections with it, read only when the environment says so, so a batch of queries can run side
|
|
11
|
+
* by side without asking for anything twice. Connections beyond the first that the server refuses
|
|
12
|
+
* (too many clients, say) are left out; the first failing is an Error with a plain-English message.
|
|
13
|
+
*/
|
|
14
|
+
export async function connectEntries(entry, services, count, options = {}) {
|
|
15
|
+
const resolved = await resolveEntry(entry, services, { signal: options.signal });
|
|
16
|
+
const open = () => services.openSession(resolved, {
|
|
17
|
+
readOnly: options.readOnly ?? resolved.readOnly,
|
|
18
|
+
...(options.statementTimeoutMs === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: { statementTimeoutMs: options.statementTimeoutMs }),
|
|
21
|
+
});
|
|
22
|
+
const attempts = await Promise.allSettled(Array.from({ length: Math.max(count, 1) }, open));
|
|
23
|
+
const sessions = attempts.flatMap((attempt) => attempt.status === 'fulfilled' ? [attempt.value] : []);
|
|
24
|
+
if (sessions.length > 0)
|
|
25
|
+
return sessions;
|
|
26
|
+
const error = attempts[0].reason;
|
|
27
|
+
const failure = describeError(error, resolved, DEFAULT_TIMEOUT_MS);
|
|
28
|
+
if (failure.ok)
|
|
29
|
+
throw error;
|
|
30
|
+
const message = failure.detail && failure.detail !== failure.reason
|
|
31
|
+
? `${failure.reason} (${failure.detail})`
|
|
32
|
+
: failure.reason;
|
|
33
|
+
throw failure.kind === 'auth' ? new LoginError(message) : new Error(message);
|
|
34
|
+
}
|
|
8
35
|
/**
|
|
9
36
|
* Looks up everything an environment needs (from Vault, a prompt, ...) and connects, read only when
|
|
10
37
|
* the environment says so. A connection failure is an Error with a plain-English message.
|
|
11
38
|
*/
|
|
12
39
|
export async function connectEntry(entry, services, options = {}) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
return await services.openSession(resolved, {
|
|
16
|
-
readOnly: options.readOnly ?? resolved.readOnly,
|
|
17
|
-
...(options.statementTimeoutMs === undefined
|
|
18
|
-
? {}
|
|
19
|
-
: { statementTimeoutMs: options.statementTimeoutMs }),
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
catch (error) {
|
|
23
|
-
const failure = describeError(error, resolved, DEFAULT_TIMEOUT_MS);
|
|
24
|
-
if (failure.ok)
|
|
25
|
-
throw error;
|
|
26
|
-
const message = failure.detail && failure.detail !== failure.reason
|
|
27
|
-
? `${failure.reason} (${failure.detail})`
|
|
28
|
-
: failure.reason;
|
|
29
|
-
throw failure.kind === 'auth' ? new LoginError(message) : new Error(message);
|
|
30
|
-
}
|
|
40
|
+
const [session] = await connectEntries(entry, services, 1, options);
|
|
41
|
+
return session;
|
|
31
42
|
}
|