@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,140 @@
|
|
|
1
|
+
// The environments a user can pick: those from the shared config file plus their local
|
|
2
|
+
// connections, as one list. No Ink in here; screens reach it through Services.
|
|
3
|
+
import { resolveEnvironment } from './config/resolve-environment.js';
|
|
4
|
+
import { resolveValue, ResolveError } from './config/resolvers/index.js';
|
|
5
|
+
import { refKinds, } from './config/schema.js';
|
|
6
|
+
// A literal is shown as written; anything looked up elsewhere only by where it comes from.
|
|
7
|
+
const display = (value) => {
|
|
8
|
+
if (typeof value !== 'object')
|
|
9
|
+
return String(value);
|
|
10
|
+
const kind = refKinds.find((k) => k in value);
|
|
11
|
+
return `‹${kind ?? '?'}›`;
|
|
12
|
+
};
|
|
13
|
+
export const sharedKey = (name) => `shared:${name}`;
|
|
14
|
+
export const localKey = (id) => `local:${id}`;
|
|
15
|
+
/** The red mark beside an environment's name, if it has one. */
|
|
16
|
+
export const badgeOf = (entry) => entry.protected ? 'PROTECTED' : undefined;
|
|
17
|
+
/** The colour a tier is shown in, so the riskier ones stand out. */
|
|
18
|
+
export const tierColor = {
|
|
19
|
+
production: 'red',
|
|
20
|
+
reference: 'yellow',
|
|
21
|
+
demo: 'magenta',
|
|
22
|
+
development: 'green',
|
|
23
|
+
local: 'gray',
|
|
24
|
+
};
|
|
25
|
+
/** Marks a protected environment. */
|
|
26
|
+
export const shield = '⛨';
|
|
27
|
+
export const addressOf = (entry) => `${entry.user}@${entry.host}:${entry.port}/${entry.database}`;
|
|
28
|
+
/** Shared environments by name, then local connections by name. May throw if the store fails. */
|
|
29
|
+
export function listEnvironments(services) {
|
|
30
|
+
const shared = (services.config?.environments ?? []).map((config) => ({
|
|
31
|
+
key: sharedKey(config.name),
|
|
32
|
+
source: 'shared',
|
|
33
|
+
name: config.name,
|
|
34
|
+
tier: config.tier,
|
|
35
|
+
protected: config.protected,
|
|
36
|
+
readOnly: config.readOnly,
|
|
37
|
+
seeder: config.seeder,
|
|
38
|
+
designer: config.designer,
|
|
39
|
+
tlsMode: config.tls.mode,
|
|
40
|
+
user: display(config.username),
|
|
41
|
+
host: display(config.host),
|
|
42
|
+
port: display(config.port),
|
|
43
|
+
database: display(config.database),
|
|
44
|
+
config,
|
|
45
|
+
}));
|
|
46
|
+
const local = services.store.list().map((connection) => ({
|
|
47
|
+
key: localKey(connection.id),
|
|
48
|
+
source: 'local',
|
|
49
|
+
name: connection.name,
|
|
50
|
+
// Local connections are the user's own, so they never get production guards.
|
|
51
|
+
tier: 'local',
|
|
52
|
+
protected: false,
|
|
53
|
+
readOnly: false,
|
|
54
|
+
seeder: true,
|
|
55
|
+
designer: true,
|
|
56
|
+
tlsMode: connection.ssl ? 'require' : 'disable',
|
|
57
|
+
user: connection.username,
|
|
58
|
+
host: connection.host,
|
|
59
|
+
port: String(connection.port),
|
|
60
|
+
database: connection.database,
|
|
61
|
+
connection,
|
|
62
|
+
}));
|
|
63
|
+
return [...shared, ...local];
|
|
64
|
+
}
|
|
65
|
+
/** Looks up and validates everything needed to connect; may prompt, and may throw a ResolveError. */
|
|
66
|
+
export async function resolveEntry(entry, services,
|
|
67
|
+
// `interactive: false` fails instead of asking, unless the answer is already known.
|
|
68
|
+
options = {}) {
|
|
69
|
+
const { config, connection } = entry;
|
|
70
|
+
if (config) {
|
|
71
|
+
if (!services.config)
|
|
72
|
+
throw new Error('No shared config is loaded');
|
|
73
|
+
return resolveEnvironment(config, {
|
|
74
|
+
interactive: options.interactive ?? true,
|
|
75
|
+
registry: services.resolvers,
|
|
76
|
+
signal: options.signal,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (!connection)
|
|
80
|
+
throw new Error(`Environment ${entry.name} has no connection details`);
|
|
81
|
+
return {
|
|
82
|
+
name: connection.name,
|
|
83
|
+
tier: 'local',
|
|
84
|
+
protected: false,
|
|
85
|
+
readOnly: false,
|
|
86
|
+
host: connection.host,
|
|
87
|
+
port: connection.port,
|
|
88
|
+
database: connection.database,
|
|
89
|
+
username: connection.username,
|
|
90
|
+
password: connection.password,
|
|
91
|
+
schema: connection.schema,
|
|
92
|
+
tls: { mode: connection.ssl ? 'require' : 'disable' },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The environment a command names: a shared one of that name, else a local connection of that name
|
|
97
|
+
* in any letter case. A shared environment wins when both have it.
|
|
98
|
+
*/
|
|
99
|
+
export function findEnvironment(services, name) {
|
|
100
|
+
const entries = listEnvironments(services);
|
|
101
|
+
return (entries.find((entry) => entry.source === 'shared' && entry.name === name) ??
|
|
102
|
+
entries.find((entry) => entry.source === 'local' && entry.name.toLowerCase() === name.toLowerCase()));
|
|
103
|
+
}
|
|
104
|
+
/** Drops the answers typed for an environment, so the next attempt asks again (after a wrong password, say). */
|
|
105
|
+
export function forgetAnswers(entry, services) {
|
|
106
|
+
services.resolvers.get('prompt')?.forget(entry.name);
|
|
107
|
+
}
|
|
108
|
+
const vaultValues = (config) => [
|
|
109
|
+
['host', config.host],
|
|
110
|
+
['port', config.port],
|
|
111
|
+
['database', config.database],
|
|
112
|
+
['schema', config.schema],
|
|
113
|
+
['username', config.username],
|
|
114
|
+
['password', config.password],
|
|
115
|
+
['tls.ca', config.tls.ca],
|
|
116
|
+
['tls.cert', config.tls.cert],
|
|
117
|
+
['tls.key', config.tls.key],
|
|
118
|
+
['tls.keyPassphrase', config.tls.keyPassphrase],
|
|
119
|
+
].filter((pair) => typeof pair[1] === 'object' && 'vault' in pair[1]);
|
|
120
|
+
/**
|
|
121
|
+
* Whether Vault lets this user read the secrets an environment refers to. Only `source: vault` values
|
|
122
|
+
* are read (a read changes nothing in Vault); issuing credentials or a certificate is left to the
|
|
123
|
+
* moment of connecting. Any other failure, such as Vault being unreachable, is not a refusal and
|
|
124
|
+
* counts as readable: connecting reports it.
|
|
125
|
+
*/
|
|
126
|
+
export async function credentialsReadable(entry, services, signal) {
|
|
127
|
+
const { config } = entry;
|
|
128
|
+
if (!config || !services.config)
|
|
129
|
+
return true;
|
|
130
|
+
for (const [field, value] of vaultValues(config)) {
|
|
131
|
+
try {
|
|
132
|
+
await resolveValue(value, { envName: config.name, field, configDir: config.dir, interactive: false, signal }, services.resolvers);
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error instanceof ResolveError && error.denied)
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
@@ -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
|
+
}
|