@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,502 @@
|
|
|
1
|
+
// Turns the design of a table into the statements that create it. Pure: nothing here talks to a
|
|
2
|
+
// database. Every identifier is quoted; expressions (defaults, checks, index predicates) are SQL the
|
|
3
|
+
// designer typed, and are shown to them before anything runs.
|
|
4
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
5
|
+
export class DdlError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export const BUILTIN_TYPES = [
|
|
8
|
+
{ name: 'smallint' },
|
|
9
|
+
{ name: 'integer' },
|
|
10
|
+
{ name: 'bigint' },
|
|
11
|
+
{ name: 'numeric', sized: 'numeric' },
|
|
12
|
+
{ name: 'real' },
|
|
13
|
+
{ name: 'double precision' },
|
|
14
|
+
{ name: 'money' },
|
|
15
|
+
{ name: 'boolean' },
|
|
16
|
+
{ name: 'text' },
|
|
17
|
+
{ name: 'varchar', sized: 'length' },
|
|
18
|
+
{ name: 'char', sized: 'length' },
|
|
19
|
+
{ name: 'citext' },
|
|
20
|
+
{ name: 'bytea' },
|
|
21
|
+
{ name: 'uuid' },
|
|
22
|
+
{ name: 'date' },
|
|
23
|
+
{ name: 'time', sized: 'precision' },
|
|
24
|
+
{ name: 'timetz', sized: 'precision' },
|
|
25
|
+
{ name: 'timestamp', sized: 'precision' },
|
|
26
|
+
{ name: 'timestamptz', sized: 'precision' },
|
|
27
|
+
{ name: 'interval', sized: 'precision' },
|
|
28
|
+
{ name: 'json' },
|
|
29
|
+
{ name: 'jsonb' },
|
|
30
|
+
{ name: 'xml' },
|
|
31
|
+
{ name: 'inet' },
|
|
32
|
+
{ name: 'cidr' },
|
|
33
|
+
{ name: 'macaddr' },
|
|
34
|
+
{ name: 'bit', sized: 'length' },
|
|
35
|
+
{ name: 'varbit', sized: 'length' },
|
|
36
|
+
{ name: 'tsvector' },
|
|
37
|
+
{ name: 'tsquery' },
|
|
38
|
+
{ name: 'point' },
|
|
39
|
+
{ name: 'int4range' },
|
|
40
|
+
{ name: 'int8range' },
|
|
41
|
+
{ name: 'numrange' },
|
|
42
|
+
{ name: 'daterange' },
|
|
43
|
+
{ name: 'tsrange' },
|
|
44
|
+
{ name: 'tstzrange' },
|
|
45
|
+
];
|
|
46
|
+
/** Defaults the form offers besides a free expression. */
|
|
47
|
+
export const DEFAULT_PRESETS = [
|
|
48
|
+
'now()',
|
|
49
|
+
'current_timestamp',
|
|
50
|
+
'current_date',
|
|
51
|
+
'gen_random_uuid()',
|
|
52
|
+
'true',
|
|
53
|
+
'false',
|
|
54
|
+
"''",
|
|
55
|
+
'0',
|
|
56
|
+
"'{}'::jsonb",
|
|
57
|
+
"'[]'::jsonb",
|
|
58
|
+
];
|
|
59
|
+
export const literal = (text) => {
|
|
60
|
+
if (text.includes('\0'))
|
|
61
|
+
throw new DdlError('Text cannot hold a null character');
|
|
62
|
+
return `'${text.replaceAll("'", "''")}'`;
|
|
63
|
+
};
|
|
64
|
+
const quoteAll = (names) => names.map((name) => quoteIdentifier(name)).join(', ');
|
|
65
|
+
export const expression = (sql, what) => {
|
|
66
|
+
const trimmed = sql.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
throw new DdlError(`${what} is empty`);
|
|
69
|
+
return trimmed;
|
|
70
|
+
};
|
|
71
|
+
function integer(value, what, min) {
|
|
72
|
+
if (!Number.isInteger(value) || value < min)
|
|
73
|
+
throw new DdlError(`${what} must be a whole number`);
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
/** A type as written in SQL: `varchar(20)`, `numeric(10, 2)`, `"app"."mood"[]`. */
|
|
77
|
+
export function typeSql(type) {
|
|
78
|
+
let sql;
|
|
79
|
+
if (type.schema !== undefined) {
|
|
80
|
+
sql = `${quoteIdentifier(type.schema)}.${quoteIdentifier(type.name)}`;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const builtin = BUILTIN_TYPES.find((candidate) => candidate.name === type.name);
|
|
84
|
+
if (!builtin)
|
|
85
|
+
throw new DdlError(`Unknown type "${type.name}"`);
|
|
86
|
+
sql = builtin.name;
|
|
87
|
+
if (builtin.sized === 'length' && type.length !== undefined) {
|
|
88
|
+
sql += `(${integer(type.length, 'A length', 1)})`;
|
|
89
|
+
}
|
|
90
|
+
else if (builtin.sized === 'precision' && type.length !== undefined) {
|
|
91
|
+
sql += `(${integer(type.length, 'A precision', 0)})`;
|
|
92
|
+
}
|
|
93
|
+
else if (builtin.sized === 'numeric' && type.precision !== undefined) {
|
|
94
|
+
const precision = integer(type.precision, 'A precision', 1);
|
|
95
|
+
sql +=
|
|
96
|
+
type.scale === undefined
|
|
97
|
+
? `(${precision})`
|
|
98
|
+
: `(${precision}, ${integer(type.scale, 'A scale', 0)})`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return sql + '[]'.repeat(type.array ? integer(type.array, 'Array dimensions', 0) : 0);
|
|
102
|
+
}
|
|
103
|
+
const POSTGRES_NAME_LIMIT = 63;
|
|
104
|
+
const clip = (name, suffix) => name.length + suffix.length <= POSTGRES_NAME_LIMIT
|
|
105
|
+
? name + suffix
|
|
106
|
+
: name.slice(0, POSTGRES_NAME_LIMIT - suffix.length) + suffix;
|
|
107
|
+
/** The name Postgres would give a constraint: `orders_customer_id_fkey`. */
|
|
108
|
+
export function suggestConstraintName(table, constraint) {
|
|
109
|
+
switch (constraint.kind) {
|
|
110
|
+
case 'primary':
|
|
111
|
+
return clip(table, '_pkey');
|
|
112
|
+
case 'unique':
|
|
113
|
+
return clip(`${table}_${constraint.columns.join('_')}`, '_key');
|
|
114
|
+
case 'foreign':
|
|
115
|
+
return clip(`${table}_${constraint.columns.join('_')}`, '_fkey');
|
|
116
|
+
case 'check':
|
|
117
|
+
return clip(table, '_check');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The name Postgres would give an index: `orders_customer_id_idx`. */
|
|
121
|
+
export function suggestIndexName(table, index) {
|
|
122
|
+
const names = index.items.map((item) => ('column' in item ? item.column : 'expr'));
|
|
123
|
+
return clip(`${table}_${names.join('_')}`, '_idx');
|
|
124
|
+
}
|
|
125
|
+
function checkNames(what, names) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const name of names) {
|
|
128
|
+
if (name === undefined)
|
|
129
|
+
continue;
|
|
130
|
+
if (!name)
|
|
131
|
+
throw new DdlError(`A ${what} name is empty`);
|
|
132
|
+
if (seen.has(name))
|
|
133
|
+
throw new DdlError(`Two ${what}s are named "${name}"`);
|
|
134
|
+
seen.add(name);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function columnSql(column) {
|
|
138
|
+
const parts = [quoteIdentifier(column.name), typeSql(column.type)];
|
|
139
|
+
const defaultSql = column.default?.trim();
|
|
140
|
+
if (column.generated !== undefined) {
|
|
141
|
+
if (defaultSql || column.identity) {
|
|
142
|
+
throw new DdlError(`Column "${column.name}" is generated, so has no default or identity`);
|
|
143
|
+
}
|
|
144
|
+
parts.push(`GENERATED ALWAYS AS (${expression(column.generated, 'A generated expression')}) STORED`);
|
|
145
|
+
}
|
|
146
|
+
else if (column.identity) {
|
|
147
|
+
if (defaultSql) {
|
|
148
|
+
throw new DdlError(`Column "${column.name}" is an identity, so has no default`);
|
|
149
|
+
}
|
|
150
|
+
parts.push(`GENERATED ${column.identity === 'always' ? 'ALWAYS' : 'BY DEFAULT'} AS IDENTITY`);
|
|
151
|
+
}
|
|
152
|
+
else if (defaultSql) {
|
|
153
|
+
parts.push(`DEFAULT ${defaultSql}`);
|
|
154
|
+
}
|
|
155
|
+
if (column.notNull)
|
|
156
|
+
parts.push('NOT NULL');
|
|
157
|
+
return parts.join(' ');
|
|
158
|
+
}
|
|
159
|
+
export function constraintSql(constraint, options, columns) {
|
|
160
|
+
const own = (names) => {
|
|
161
|
+
if (names.length === 0)
|
|
162
|
+
throw new DdlError(`A ${constraint.kind} constraint needs a column`);
|
|
163
|
+
for (const name of names) {
|
|
164
|
+
if (!columns.has(name))
|
|
165
|
+
throw new DdlError(`There is no column "${name}" to constrain`);
|
|
166
|
+
}
|
|
167
|
+
if (new Set(names).size !== names.length) {
|
|
168
|
+
throw new DdlError(`A ${constraint.kind} constraint lists a column twice`);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const named = constraint.name === undefined ? '' : `CONSTRAINT ${quoteIdentifier(constraint.name)} `;
|
|
172
|
+
switch (constraint.kind) {
|
|
173
|
+
case 'primary':
|
|
174
|
+
own(constraint.columns);
|
|
175
|
+
return `${named}PRIMARY KEY (${quoteAll(constraint.columns)})`;
|
|
176
|
+
case 'unique': {
|
|
177
|
+
own(constraint.columns);
|
|
178
|
+
if (constraint.nullsNotDistinct && (options.serverMajor ?? 15) < 15) {
|
|
179
|
+
throw new DdlError('NULLS NOT DISTINCT needs PostgreSQL 15 or later');
|
|
180
|
+
}
|
|
181
|
+
const nulls = constraint.nullsNotDistinct ? ' NULLS NOT DISTINCT' : '';
|
|
182
|
+
return `${named}UNIQUE${nulls} (${quoteAll(constraint.columns)})`;
|
|
183
|
+
}
|
|
184
|
+
case 'check':
|
|
185
|
+
return `${named}CHECK (${expression(constraint.expression, 'A check')})`;
|
|
186
|
+
case 'foreign': {
|
|
187
|
+
own(constraint.columns);
|
|
188
|
+
const target = constraint.references;
|
|
189
|
+
if (target.columns.length !== constraint.columns.length) {
|
|
190
|
+
throw new DdlError('A foreign key needs as many referenced columns as columns');
|
|
191
|
+
}
|
|
192
|
+
let sql = `${named}FOREIGN KEY (${quoteAll(constraint.columns)}) REFERENCES `;
|
|
193
|
+
sql += `${quoteIdentifier(target.schema)}.${quoteIdentifier(target.table)} (${quoteAll(target.columns)})`;
|
|
194
|
+
if (constraint.matchFull)
|
|
195
|
+
sql += ' MATCH FULL';
|
|
196
|
+
if (constraint.onDelete)
|
|
197
|
+
sql += ` ON DELETE ${constraint.onDelete.toUpperCase()}`;
|
|
198
|
+
if (constraint.onUpdate)
|
|
199
|
+
sql += ` ON UPDATE ${constraint.onUpdate.toUpperCase()}`;
|
|
200
|
+
if (constraint.initiallyDeferred && !constraint.deferrable) {
|
|
201
|
+
throw new DdlError('A foreign key can only start deferred if it is deferrable');
|
|
202
|
+
}
|
|
203
|
+
if (constraint.deferrable) {
|
|
204
|
+
sql += ' DEFERRABLE' + (constraint.initiallyDeferred ? ' INITIALLY DEFERRED' : '');
|
|
205
|
+
}
|
|
206
|
+
return sql;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
export function indexSql(table, index, columns) {
|
|
211
|
+
const method = index.method ?? 'btree';
|
|
212
|
+
if (index.items.length === 0)
|
|
213
|
+
throw new DdlError('An index needs a column or an expression');
|
|
214
|
+
if (index.unique && method !== 'btree') {
|
|
215
|
+
throw new DdlError(`A ${method} index cannot be unique`);
|
|
216
|
+
}
|
|
217
|
+
if (index.include?.length && method !== 'btree' && method !== 'gist') {
|
|
218
|
+
throw new DdlError(`A ${method} index cannot have INCLUDE columns`);
|
|
219
|
+
}
|
|
220
|
+
const items = index.items.map((item) => {
|
|
221
|
+
let sql;
|
|
222
|
+
if ('column' in item) {
|
|
223
|
+
if (!columns.has(item.column))
|
|
224
|
+
throw new DdlError(`There is no column "${item.column}" to index`);
|
|
225
|
+
sql = quoteIdentifier(item.column);
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
sql = `(${expression(item.expression, 'An index expression')})`;
|
|
229
|
+
}
|
|
230
|
+
return item.descending ? `${sql} DESC` : sql;
|
|
231
|
+
});
|
|
232
|
+
for (const name of index.include ?? []) {
|
|
233
|
+
if (!columns.has(name))
|
|
234
|
+
throw new DdlError(`There is no column "${name}" to include`);
|
|
235
|
+
}
|
|
236
|
+
let sql = `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX `;
|
|
237
|
+
if (index.name !== undefined)
|
|
238
|
+
sql += `${quoteIdentifier(index.name)} `;
|
|
239
|
+
sql += `ON ${quoteIdentifier(table.schema)}.${quoteIdentifier(table.name)}`;
|
|
240
|
+
sql += ` USING ${method} (${items.join(', ')})`;
|
|
241
|
+
if (index.include?.length)
|
|
242
|
+
sql += ` INCLUDE (${quoteAll(index.include)})`;
|
|
243
|
+
if (index.where?.trim())
|
|
244
|
+
sql += ` WHERE ${index.where.trim()}`;
|
|
245
|
+
return sql;
|
|
246
|
+
}
|
|
247
|
+
function build(table, options,
|
|
248
|
+
// Tables of the same design that come later, whose foreign keys cannot be inline yet.
|
|
249
|
+
later) {
|
|
250
|
+
if (!table.schema)
|
|
251
|
+
throw new DdlError('The table needs a schema');
|
|
252
|
+
if (!table.name)
|
|
253
|
+
throw new DdlError('The table needs a name');
|
|
254
|
+
if (table.columns.length === 0)
|
|
255
|
+
throw new DdlError('The table needs a column');
|
|
256
|
+
checkNames('column', table.columns.map((column) => column.name));
|
|
257
|
+
for (const column of table.columns) {
|
|
258
|
+
if (!column.name)
|
|
259
|
+
throw new DdlError('A column needs a name');
|
|
260
|
+
}
|
|
261
|
+
const columns = new Set(table.columns.map((column) => column.name));
|
|
262
|
+
const constraints = table.constraints ?? [];
|
|
263
|
+
checkNames('constraint', constraints.map((constraint) => constraint.name));
|
|
264
|
+
checkNames('index', (table.indexes ?? []).map((index) => index.name));
|
|
265
|
+
if (constraints.filter((constraint) => constraint.kind === 'primary').length > 1) {
|
|
266
|
+
throw new DdlError('A table has only one primary key');
|
|
267
|
+
}
|
|
268
|
+
if (table.partition) {
|
|
269
|
+
if (table.unlogged)
|
|
270
|
+
throw new DdlError('A partitioned table cannot be unlogged');
|
|
271
|
+
const key = table.partition.column;
|
|
272
|
+
if (!columns.has(key))
|
|
273
|
+
throw new DdlError(`There is no column "${key}" to partition by`);
|
|
274
|
+
for (const constraint of constraints) {
|
|
275
|
+
if ((constraint.kind === 'primary' || constraint.kind === 'unique') &&
|
|
276
|
+
!constraint.columns.includes(key)) {
|
|
277
|
+
throw new DdlError(`A ${constraint.kind === 'primary' ? 'primary key' : 'unique constraint'} of a partitioned table must include "${key}"`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const qualifiedName = `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.name)}`;
|
|
282
|
+
const inline = [];
|
|
283
|
+
const alters = [];
|
|
284
|
+
for (const constraint of constraints) {
|
|
285
|
+
const sql = constraintSql(constraint, options, columns);
|
|
286
|
+
const target = constraint.kind === 'foreign' ? constraint.references : null;
|
|
287
|
+
const targetKey = target ? `${target.schema}.${target.table}` : '';
|
|
288
|
+
if (target && later.has(targetKey)) {
|
|
289
|
+
alters.push(`ALTER TABLE ${qualifiedName} ADD ${sql}`);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
inline.push(sql);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
let create = `CREATE ${table.unlogged ? 'UNLOGGED ' : ''}TABLE ${qualifiedName} (\n`;
|
|
296
|
+
create += [...table.columns.map(columnSql), ...inline].map((line) => ` ${line}`).join(',\n');
|
|
297
|
+
create += '\n)';
|
|
298
|
+
if (table.partition) {
|
|
299
|
+
create += ` PARTITION BY ${table.partition.method.toUpperCase()} (${quoteIdentifier(table.partition.column)})`;
|
|
300
|
+
}
|
|
301
|
+
const rest = [];
|
|
302
|
+
for (const index of table.indexes ?? [])
|
|
303
|
+
rest.push(indexSql(table, index, columns));
|
|
304
|
+
if (table.comment)
|
|
305
|
+
rest.push(`COMMENT ON TABLE ${qualifiedName} IS ${literal(table.comment)}`);
|
|
306
|
+
for (const column of table.columns) {
|
|
307
|
+
if (column.comment) {
|
|
308
|
+
rest.push(`COMMENT ON COLUMN ${qualifiedName}.${quoteIdentifier(column.name)} IS ${literal(column.comment)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return { create, alters, rest };
|
|
312
|
+
}
|
|
313
|
+
/** The statements that create one table: the table, then its indexes and comments. */
|
|
314
|
+
export function buildDdl(table, options = {}) {
|
|
315
|
+
const built = build(table, options, new Set());
|
|
316
|
+
return [built.create, ...built.rest];
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* The statements that create several tables, in the order given. A foreign key to a table that
|
|
320
|
+
* comes later is added with `ALTER TABLE` once every table exists, so any order works.
|
|
321
|
+
*/
|
|
322
|
+
export function buildDdlMany(tables, options = {}) {
|
|
323
|
+
const keys = tables.map((table) => `${table.schema}.${table.name}`);
|
|
324
|
+
if (new Set(keys).size !== keys.length)
|
|
325
|
+
throw new DdlError('Two tables have the same name');
|
|
326
|
+
const creates = [];
|
|
327
|
+
const later = [];
|
|
328
|
+
const rest = [];
|
|
329
|
+
tables.forEach((table, at) => {
|
|
330
|
+
const ahead = new Set(keys.slice(at + 1));
|
|
331
|
+
const built = build(table, options, ahead);
|
|
332
|
+
creates.push(built.create);
|
|
333
|
+
later.push(...built.alters);
|
|
334
|
+
rest.push(...built.rest);
|
|
335
|
+
});
|
|
336
|
+
return [...creates, ...later, ...rest];
|
|
337
|
+
}
|
|
338
|
+
// --- Reading a design from JSON -----------------------------------------------------------------
|
|
339
|
+
const ACTIONS = [
|
|
340
|
+
'no action',
|
|
341
|
+
'restrict',
|
|
342
|
+
'cascade',
|
|
343
|
+
'set null',
|
|
344
|
+
'set default',
|
|
345
|
+
];
|
|
346
|
+
const METHODS = ['btree', 'hash', 'gin', 'gist', 'brin'];
|
|
347
|
+
const isObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
348
|
+
function object(value, what) {
|
|
349
|
+
if (!isObject(value))
|
|
350
|
+
throw new DdlError(`${what} must be an object`);
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
function text(value, what) {
|
|
354
|
+
if (typeof value !== 'string')
|
|
355
|
+
throw new DdlError(`${what} must be text`);
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
const optionalText = (value, what) => value === undefined || value === null ? undefined : text(value, what);
|
|
359
|
+
const optionalFlag = (value, what) => {
|
|
360
|
+
if (value === undefined || value === null)
|
|
361
|
+
return undefined;
|
|
362
|
+
if (typeof value !== 'boolean')
|
|
363
|
+
throw new DdlError(`${what} must be true or false`);
|
|
364
|
+
return value || undefined;
|
|
365
|
+
};
|
|
366
|
+
const optionalNumber = (value, what) => {
|
|
367
|
+
if (value === undefined || value === null)
|
|
368
|
+
return undefined;
|
|
369
|
+
if (typeof value !== 'number')
|
|
370
|
+
throw new DdlError(`${what} must be a number`);
|
|
371
|
+
return value;
|
|
372
|
+
};
|
|
373
|
+
function list(value, what) {
|
|
374
|
+
if (value === undefined || value === null)
|
|
375
|
+
return [];
|
|
376
|
+
if (!Array.isArray(value))
|
|
377
|
+
throw new DdlError(`${what} must be a list`);
|
|
378
|
+
return value;
|
|
379
|
+
}
|
|
380
|
+
const names = (value, what) => list(value, what).map((name) => text(name, what));
|
|
381
|
+
function oneOf(value, allowed, what) {
|
|
382
|
+
if (value === undefined || value === null || value === '')
|
|
383
|
+
return undefined;
|
|
384
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
385
|
+
throw new DdlError(`${what} must be one of ${allowed.join(', ')}`);
|
|
386
|
+
}
|
|
387
|
+
return value;
|
|
388
|
+
}
|
|
389
|
+
function parseType(value) {
|
|
390
|
+
const raw = object(value, 'A column type');
|
|
391
|
+
const schema = optionalText(raw.schema, 'A type schema');
|
|
392
|
+
return {
|
|
393
|
+
name: text(raw.name, 'A type name'),
|
|
394
|
+
...(schema ? { schema } : {}),
|
|
395
|
+
...pick('length', optionalNumber(raw.length, 'A length')),
|
|
396
|
+
...pick('precision', optionalNumber(raw.precision, 'A precision')),
|
|
397
|
+
...pick('scale', optionalNumber(raw.scale, 'A scale')),
|
|
398
|
+
...pick('array', optionalNumber(raw.array, 'Array dimensions')),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const pick = (key, value) => (value === undefined ? {} : { [key]: value });
|
|
402
|
+
function parseColumn(value) {
|
|
403
|
+
const raw = object(value, 'A column');
|
|
404
|
+
return {
|
|
405
|
+
...pick('id', optionalText(raw.id, 'An id')),
|
|
406
|
+
name: text(raw.name, 'A column name'),
|
|
407
|
+
type: parseType(raw.type),
|
|
408
|
+
...pick('notNull', optionalFlag(raw.notNull, 'notNull')),
|
|
409
|
+
...pick('default', optionalText(raw.default, 'A default')),
|
|
410
|
+
...pick('identity', oneOf(raw.identity, ['always', 'by default'], 'identity')),
|
|
411
|
+
...pick('generated', optionalText(raw.generated, 'A generated expression')),
|
|
412
|
+
...pick('comment', optionalText(raw.comment, 'A comment')),
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function parseConstraint(value) {
|
|
416
|
+
const raw = object(value, 'A constraint');
|
|
417
|
+
const name = optionalText(raw.name, 'A constraint name');
|
|
418
|
+
const named = { ...(name ? { name } : {}), ...pick('id', optionalText(raw.id, 'An id')) };
|
|
419
|
+
switch (raw.kind) {
|
|
420
|
+
case 'primary':
|
|
421
|
+
return { kind: 'primary', ...named, columns: names(raw.columns, 'A column name') };
|
|
422
|
+
case 'unique':
|
|
423
|
+
return {
|
|
424
|
+
kind: 'unique',
|
|
425
|
+
...named,
|
|
426
|
+
columns: names(raw.columns, 'A column name'),
|
|
427
|
+
...pick('nullsNotDistinct', optionalFlag(raw.nullsNotDistinct, 'nullsNotDistinct')),
|
|
428
|
+
};
|
|
429
|
+
case 'check':
|
|
430
|
+
return { kind: 'check', ...named, expression: text(raw.expression, 'A check expression') };
|
|
431
|
+
case 'foreign': {
|
|
432
|
+
const target = object(raw.references, 'A foreign key target');
|
|
433
|
+
return {
|
|
434
|
+
kind: 'foreign',
|
|
435
|
+
...named,
|
|
436
|
+
columns: names(raw.columns, 'A column name'),
|
|
437
|
+
references: {
|
|
438
|
+
schema: text(target.schema, 'A target schema'),
|
|
439
|
+
table: text(target.table, 'A target table'),
|
|
440
|
+
columns: names(target.columns, 'A target column'),
|
|
441
|
+
},
|
|
442
|
+
...pick('onDelete', oneOf(raw.onDelete, ACTIONS, 'onDelete')),
|
|
443
|
+
...pick('onUpdate', oneOf(raw.onUpdate, ACTIONS, 'onUpdate')),
|
|
444
|
+
...pick('matchFull', optionalFlag(raw.matchFull, 'matchFull')),
|
|
445
|
+
...pick('deferrable', optionalFlag(raw.deferrable, 'deferrable')),
|
|
446
|
+
...pick('initiallyDeferred', optionalFlag(raw.initiallyDeferred, 'initiallyDeferred')),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
default:
|
|
450
|
+
throw new DdlError('A constraint is primary, unique, check or foreign');
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function parseIndex(value) {
|
|
454
|
+
const raw = object(value, 'An index');
|
|
455
|
+
const name = optionalText(raw.name, 'An index name');
|
|
456
|
+
const items = list(raw.items, 'An index item list').map((item) => {
|
|
457
|
+
const entry = object(item, 'An index item');
|
|
458
|
+
const descending = optionalFlag(entry.descending, 'descending');
|
|
459
|
+
if (typeof entry.column === 'string') {
|
|
460
|
+
return { column: entry.column, ...(descending ? { descending } : {}) };
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
expression: text(entry.expression, 'An index expression'),
|
|
464
|
+
...(descending ? { descending } : {}),
|
|
465
|
+
};
|
|
466
|
+
});
|
|
467
|
+
const include = names(raw.include, 'An include column');
|
|
468
|
+
return {
|
|
469
|
+
...pick('id', optionalText(raw.id, 'An id')),
|
|
470
|
+
...(name ? { name } : {}),
|
|
471
|
+
...pick('unique', optionalFlag(raw.unique, 'unique')),
|
|
472
|
+
...pick('method', oneOf(raw.method, METHODS, 'method')),
|
|
473
|
+
items,
|
|
474
|
+
...(include.length > 0 ? { include } : {}),
|
|
475
|
+
...pick('where', optionalText(raw.where, 'A WHERE')),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Reads a design sent as JSON, refusing anything that is not one: the builder puts names in quotes,
|
|
480
|
+
* but a few words (the actions, the index method) are written as they are, so they are checked here.
|
|
481
|
+
*/
|
|
482
|
+
export function parseTableDesign(value) {
|
|
483
|
+
const raw = object(value, 'A design');
|
|
484
|
+
let partition;
|
|
485
|
+
if (raw.partition !== undefined && raw.partition !== null) {
|
|
486
|
+
const entry = object(raw.partition, 'The partitioning');
|
|
487
|
+
const method = oneOf(entry.method, ['range', 'list', 'hash'], 'The partition method');
|
|
488
|
+
if (!method)
|
|
489
|
+
throw new DdlError('The partition method must be one of range, list, hash');
|
|
490
|
+
partition = { method, column: text(entry.column, 'The partition column') };
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
schema: text(raw.schema, 'The schema'),
|
|
494
|
+
name: text(raw.name, 'The table name'),
|
|
495
|
+
columns: list(raw.columns, 'The columns').map(parseColumn),
|
|
496
|
+
constraints: list(raw.constraints, 'The constraints').map(parseConstraint),
|
|
497
|
+
indexes: list(raw.indexes, 'The indexes').map(parseIndex),
|
|
498
|
+
...pick('unlogged', optionalFlag(raw.unlogged, 'unlogged')),
|
|
499
|
+
...pick('comment', optionalText(raw.comment, 'The comment')),
|
|
500
|
+
...(partition ? { partition } : {}),
|
|
501
|
+
};
|
|
502
|
+
}
|