@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,1376 @@
|
|
|
1
|
+
import { quoteIdentifier } from '../db/identifiers.js';
|
|
2
|
+
import { listColumns, qualified } from './catalog.js';
|
|
3
|
+
import { listForeignKeys } from './references.js';
|
|
4
|
+
export class SeedError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** The most rows one run inserts. */
|
|
7
|
+
export const MAX_SEED_ROWS = 100_000;
|
|
8
|
+
/** Rows generated for a preview. */
|
|
9
|
+
export const PREVIEW_ROWS = 10;
|
|
10
|
+
// Postgres takes at most 65535 parameters a statement; keep well below it.
|
|
11
|
+
const MAX_PARAMS = 30_000;
|
|
12
|
+
const MAX_BATCH_ROWS = 1000;
|
|
13
|
+
// Existing values of a referenced table that are picked from.
|
|
14
|
+
const REFERENCE_SAMPLE = 5000;
|
|
15
|
+
// Tries at a value not made before, for a column that must be unique.
|
|
16
|
+
const UNIQUE_TRIES = 100;
|
|
17
|
+
// Rows of all the "each" links put together, a run may go through.
|
|
18
|
+
const MAX_COMBINATIONS = 1_000_000;
|
|
19
|
+
export const seedLocales = [
|
|
20
|
+
'en',
|
|
21
|
+
'en_GB',
|
|
22
|
+
'en_US',
|
|
23
|
+
'de',
|
|
24
|
+
'es',
|
|
25
|
+
'fr',
|
|
26
|
+
'it',
|
|
27
|
+
'ja',
|
|
28
|
+
'nl',
|
|
29
|
+
'pt_BR',
|
|
30
|
+
];
|
|
31
|
+
export const conditionOps = [
|
|
32
|
+
'eq',
|
|
33
|
+
'ne',
|
|
34
|
+
'in',
|
|
35
|
+
'gt',
|
|
36
|
+
'gte',
|
|
37
|
+
'lt',
|
|
38
|
+
'lte',
|
|
39
|
+
'null',
|
|
40
|
+
'notNull',
|
|
41
|
+
];
|
|
42
|
+
/** Values an array holds when the plan does not say. */
|
|
43
|
+
export const DEFAULT_ITEMS = { min: 1, max: 3 };
|
|
44
|
+
// The most values one generated array holds.
|
|
45
|
+
const MAX_ITEMS = 100;
|
|
46
|
+
/** Whether a column holds arrays, e.g. "text[]" or "integer[][]". */
|
|
47
|
+
export const isArrayType = (type) => type.endsWith('[]');
|
|
48
|
+
/**
|
|
49
|
+
* Generators that fill a whole array themselves (or are written as they are), so an array column
|
|
50
|
+
* does not repeat them for each item.
|
|
51
|
+
*/
|
|
52
|
+
export const wholeArrayGenerators = new Set([
|
|
53
|
+
'default',
|
|
54
|
+
'null',
|
|
55
|
+
'constant',
|
|
56
|
+
'someOf',
|
|
57
|
+
'reference',
|
|
58
|
+
'link',
|
|
59
|
+
]);
|
|
60
|
+
// Generators whose value is the row's linked row's, so trying again never makes another.
|
|
61
|
+
const linkedGenerators = new Set(['reference', 'link']);
|
|
62
|
+
export const linkModes = ['random', 'each'];
|
|
63
|
+
const number = (options, name, fallback) => {
|
|
64
|
+
const value = Number(options[name]);
|
|
65
|
+
return options[name] === undefined || options[name] === '' || !Number.isFinite(value)
|
|
66
|
+
? fallback
|
|
67
|
+
: value;
|
|
68
|
+
};
|
|
69
|
+
const text = (options, name, fallback = '') => {
|
|
70
|
+
const value = options[name];
|
|
71
|
+
return value === undefined || value === '' ? fallback : String(value);
|
|
72
|
+
};
|
|
73
|
+
const date = (options, name, fallback) => {
|
|
74
|
+
const value = new Date(text(options, name, fallback));
|
|
75
|
+
if (Number.isNaN(value.getTime()))
|
|
76
|
+
throw new SeedError(`"${text(options, name)}" is not a date`);
|
|
77
|
+
return value;
|
|
78
|
+
};
|
|
79
|
+
/** A list typed one item a line, or comma separated. */
|
|
80
|
+
export const listOf = (value) => value
|
|
81
|
+
.split(value.includes('\n') ? '\n' : ',')
|
|
82
|
+
.map((item) => item.trim())
|
|
83
|
+
.filter((item) => item !== '');
|
|
84
|
+
// {{row.column_name}} in a template.
|
|
85
|
+
const rowReference = /\{\{\s*row\.([^}\s]+)\s*\}\}/g;
|
|
86
|
+
// {{link.name.column}} in a template: a column of the row the link gives this row.
|
|
87
|
+
const linkReference = /\{\{\s*link\.([^.}\s]+)\.([^}\s]+)\s*\}\}/g;
|
|
88
|
+
/** The columns a plan reads from its row: those its rules test and its templates name. */
|
|
89
|
+
export function dependenciesOf(chosen) {
|
|
90
|
+
const found = new Set();
|
|
91
|
+
const visit = (each) => {
|
|
92
|
+
if (each.generator === 'template') {
|
|
93
|
+
for (const match of text(each.options ?? {}, 'template').matchAll(rowReference))
|
|
94
|
+
found.add(match[1]);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
visit(chosen);
|
|
98
|
+
for (const rule of chosen.rules ?? []) {
|
|
99
|
+
found.add(rule.when.column);
|
|
100
|
+
visit(rule.then);
|
|
101
|
+
}
|
|
102
|
+
return [...found];
|
|
103
|
+
}
|
|
104
|
+
const truthy = {
|
|
105
|
+
t: 'true',
|
|
106
|
+
true: 'true',
|
|
107
|
+
f: 'false',
|
|
108
|
+
false: 'false',
|
|
109
|
+
};
|
|
110
|
+
const normal = (value) => {
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
return truthy[trimmed.toLowerCase()] ?? trimmed;
|
|
113
|
+
};
|
|
114
|
+
/** Whether a generated value meets a condition; numbers compare as numbers, else as text. */
|
|
115
|
+
export function matches(condition, cell) {
|
|
116
|
+
if (condition.op === 'null')
|
|
117
|
+
return cell === null;
|
|
118
|
+
if (condition.op === 'notNull')
|
|
119
|
+
return cell !== null;
|
|
120
|
+
if (cell === null)
|
|
121
|
+
return condition.op === 'ne';
|
|
122
|
+
const given = condition.value ?? '';
|
|
123
|
+
if (condition.op === 'eq')
|
|
124
|
+
return normal(cell) === normal(given);
|
|
125
|
+
if (condition.op === 'ne')
|
|
126
|
+
return normal(cell) !== normal(given);
|
|
127
|
+
if (condition.op === 'in')
|
|
128
|
+
return listOf(given).some((item) => normal(item) === normal(cell));
|
|
129
|
+
const [left, right] = [Number(cell), Number(given)];
|
|
130
|
+
const numeric = cell.trim() !== '' && given.trim() !== '' && !Number.isNaN(left) && !Number.isNaN(right);
|
|
131
|
+
const order = numeric ? Math.sign(left - right) : cell < given ? -1 : cell > given ? 1 : 0;
|
|
132
|
+
if (condition.op === 'gt')
|
|
133
|
+
return order > 0;
|
|
134
|
+
if (condition.op === 'gte')
|
|
135
|
+
return order >= 0;
|
|
136
|
+
if (condition.op === 'lt')
|
|
137
|
+
return order < 0;
|
|
138
|
+
return order <= 0;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* How likely each of `values` is, from the `weights` option: a number a line (or comma separated)
|
|
142
|
+
* for each value in turn, 1 where it is left out. Null when there are no weights, so every value is
|
|
143
|
+
* as likely as any other.
|
|
144
|
+
*/
|
|
145
|
+
export function weightsOf(options, values) {
|
|
146
|
+
const given = listOf(text(options, 'weights'));
|
|
147
|
+
if (given.length === 0)
|
|
148
|
+
return null;
|
|
149
|
+
const weights = values.map((value, i) => {
|
|
150
|
+
if (given[i] === undefined)
|
|
151
|
+
return 1;
|
|
152
|
+
const weight = Number(given[i]);
|
|
153
|
+
if (!Number.isFinite(weight) || weight < 0) {
|
|
154
|
+
throw new SeedError(`the weight of "${value}" is "${given[i]}": weights are numbers, 0 or more`);
|
|
155
|
+
}
|
|
156
|
+
return weight;
|
|
157
|
+
});
|
|
158
|
+
if (!weights.some((weight) => weight > 0)) {
|
|
159
|
+
throw new SeedError('every weight is 0: give at least one value a weight above 0');
|
|
160
|
+
}
|
|
161
|
+
return weights;
|
|
162
|
+
}
|
|
163
|
+
const weightsOption = {
|
|
164
|
+
name: 'weights',
|
|
165
|
+
label: 'weights (one a line, for each value in turn; 1 if left out)',
|
|
166
|
+
type: 'list',
|
|
167
|
+
default: '',
|
|
168
|
+
};
|
|
169
|
+
const between = (min, max) => [
|
|
170
|
+
{ name: 'min', label: 'min', type: 'number', default: min },
|
|
171
|
+
{ name: 'max', label: 'max', type: 'number', default: max },
|
|
172
|
+
];
|
|
173
|
+
const lengthOption = (fallback) => [
|
|
174
|
+
{ name: 'length', label: 'length', type: 'number', default: fallback },
|
|
175
|
+
];
|
|
176
|
+
const special = 'Special';
|
|
177
|
+
const definitions = [
|
|
178
|
+
{ id: 'default', label: 'Column default', group: special, make: () => undefined },
|
|
179
|
+
{ id: 'null', label: 'NULL', group: special, make: () => null },
|
|
180
|
+
{
|
|
181
|
+
id: 'constant',
|
|
182
|
+
label: 'Fixed value',
|
|
183
|
+
group: special,
|
|
184
|
+
options: [{ name: 'value', label: 'value', type: 'text', default: '' }],
|
|
185
|
+
make: ({ options }) => text(options, 'value'),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'oneOf',
|
|
189
|
+
label: 'One of a list',
|
|
190
|
+
group: special,
|
|
191
|
+
options: [{ name: 'values', label: 'values', type: 'list', default: '' }, weightsOption],
|
|
192
|
+
make: ({ faker, options }) => {
|
|
193
|
+
const values = listOf(text(options, 'values'));
|
|
194
|
+
if (values.length === 0)
|
|
195
|
+
throw new SeedError('"One of a list" needs at least one value');
|
|
196
|
+
const weights = weightsOf(options, values);
|
|
197
|
+
if (!weights)
|
|
198
|
+
return faker.helpers.arrayElement(values);
|
|
199
|
+
return faker.helpers.weightedArrayElement(values.map((value, i) => ({ value, weight: weights[i] })).filter(({ weight }) => weight > 0));
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
id: 'someOf',
|
|
204
|
+
label: 'Some of a list',
|
|
205
|
+
group: special,
|
|
206
|
+
options: [
|
|
207
|
+
{ name: 'values', label: 'values', type: 'list', default: '' },
|
|
208
|
+
{ name: 'min', label: 'min picked', type: 'number', default: 1 },
|
|
209
|
+
{ name: 'max', label: 'max picked', type: 'number', default: 3 },
|
|
210
|
+
weightsOption,
|
|
211
|
+
],
|
|
212
|
+
make: ({ faker, options }) => {
|
|
213
|
+
const values = listOf(text(options, 'values'));
|
|
214
|
+
if (values.length === 0)
|
|
215
|
+
throw new SeedError('"Some of a list" needs at least one value');
|
|
216
|
+
const weights = weightsOf(options, values);
|
|
217
|
+
// A value of weight 0 is never picked.
|
|
218
|
+
const pickable = weights ? weights.filter((weight) => weight > 0).length : values.length;
|
|
219
|
+
const min = Math.max(0, Math.min(number(options, 'min', 1), pickable));
|
|
220
|
+
const max = Math.max(min, Math.min(number(options, 'max', 3), pickable));
|
|
221
|
+
let picked;
|
|
222
|
+
if (!weights)
|
|
223
|
+
picked = new Set(faker.helpers.arrayElements(values, { min, max }));
|
|
224
|
+
else {
|
|
225
|
+
// Weighted picks without putting back: each pick is from the values not picked yet.
|
|
226
|
+
const left = values.map((value, i) => ({ value, weight: weights[i] }));
|
|
227
|
+
picked = new Set();
|
|
228
|
+
for (let count = faker.number.int({ min, max }); picked.size < count;) {
|
|
229
|
+
const value = faker.helpers.weightedArrayElement(left.filter((each) => each.weight > 0 && !picked.has(each.value)));
|
|
230
|
+
picked.add(value);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// Distinct values, in the order the list gives them.
|
|
234
|
+
return values.filter((value) => picked.has(value));
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
id: 'reference',
|
|
239
|
+
label: 'Existing row it references',
|
|
240
|
+
group: special,
|
|
241
|
+
// Prepared into the link of its column's foreign key and the column the key points at.
|
|
242
|
+
make: ({ options, linked }) => linked(text(options, 'link'), text(options, 'column')),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: 'link',
|
|
246
|
+
label: 'Column of a linked table',
|
|
247
|
+
group: special,
|
|
248
|
+
options: [
|
|
249
|
+
{ name: 'link', label: 'link', type: 'text', default: '' },
|
|
250
|
+
{ name: 'column', label: 'column', type: 'text', default: '' },
|
|
251
|
+
],
|
|
252
|
+
make: ({ options, linked }) => linked(text(options, 'link'), text(options, 'column')),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
id: 'sequence',
|
|
256
|
+
label: 'Counting up',
|
|
257
|
+
group: special,
|
|
258
|
+
options: [
|
|
259
|
+
{ name: 'start', label: 'start', type: 'number', default: 1 },
|
|
260
|
+
{ name: 'step', label: 'step', type: 'number', default: 1 },
|
|
261
|
+
],
|
|
262
|
+
make: ({ options, index }) => number(options, 'start', 1) + index * number(options, 'step', 1),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
id: 'sequenceInGroup',
|
|
266
|
+
label: 'Counting up for each linked row',
|
|
267
|
+
group: special,
|
|
268
|
+
options: [
|
|
269
|
+
{ name: 'start', label: 'start', type: 'number', default: 1 },
|
|
270
|
+
{ name: 'step', label: 'step', type: 'number', default: 1 },
|
|
271
|
+
],
|
|
272
|
+
// Starts again for each row of the "each" links, e.g. line numbers 1, 2, 3 of each order.
|
|
273
|
+
make: ({ options, indexInGroup }) => number(options, 'start', 1) + indexInGroup * number(options, 'step', 1),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
id: 'template',
|
|
277
|
+
label: 'Template',
|
|
278
|
+
group: special,
|
|
279
|
+
options: [
|
|
280
|
+
{
|
|
281
|
+
name: 'template',
|
|
282
|
+
label: 'template',
|
|
283
|
+
type: 'text',
|
|
284
|
+
default: '{{person.firstName}}-{{number.int(99)}}',
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
make: ({ faker, options, row, linked }) => {
|
|
288
|
+
try {
|
|
289
|
+
// {{row.name}} is the value of another column, and {{link.name.column}} one of a linked
|
|
290
|
+
// row; they are put in after Faker has filled in the rest, so a value that happens to
|
|
291
|
+
// hold braces is never run as a template.
|
|
292
|
+
const values = [];
|
|
293
|
+
const hold = (value) => {
|
|
294
|
+
values.push(value);
|
|
295
|
+
return `\uE000${values.length - 1}\uE000`;
|
|
296
|
+
};
|
|
297
|
+
const template = text(options, 'template')
|
|
298
|
+
.replace(linkReference, (_, link, column) => hold(linked(link, column)))
|
|
299
|
+
.replace(rowReference, (_, name) => hold(row[name] ?? null));
|
|
300
|
+
return faker.helpers
|
|
301
|
+
.fake(template)
|
|
302
|
+
.replace(/\uE000(\d+)\uE000/g, (_, i) => values[Number(i)] ?? '');
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
throw new SeedError(`the template failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
id: 'person.firstName',
|
|
311
|
+
label: 'First name',
|
|
312
|
+
group: 'Person',
|
|
313
|
+
make: ({ faker }) => faker.person.firstName(),
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
id: 'person.lastName',
|
|
317
|
+
label: 'Last name',
|
|
318
|
+
group: 'Person',
|
|
319
|
+
make: ({ faker }) => faker.person.lastName(),
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
id: 'person.fullName',
|
|
323
|
+
label: 'Full name',
|
|
324
|
+
group: 'Person',
|
|
325
|
+
make: ({ faker }) => faker.person.fullName(),
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
id: 'internet.username',
|
|
329
|
+
label: 'Username',
|
|
330
|
+
group: 'Person',
|
|
331
|
+
make: ({ faker }) => faker.internet.username(),
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
id: 'internet.email',
|
|
335
|
+
label: 'Email',
|
|
336
|
+
group: 'Person',
|
|
337
|
+
make: ({ faker }) => faker.internet.email().toLowerCase(),
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
id: 'phone.number',
|
|
341
|
+
label: 'Phone number',
|
|
342
|
+
group: 'Person',
|
|
343
|
+
make: ({ faker }) => faker.phone.number(),
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
id: 'person.jobTitle',
|
|
347
|
+
label: 'Job title',
|
|
348
|
+
group: 'Person',
|
|
349
|
+
make: ({ faker }) => faker.person.jobTitle(),
|
|
350
|
+
},
|
|
351
|
+
{ id: 'person.sex', label: 'Sex', group: 'Person', make: ({ faker }) => faker.person.sex() },
|
|
352
|
+
{ id: 'person.bio', label: 'Bio', group: 'Person', make: ({ faker }) => faker.person.bio() },
|
|
353
|
+
{
|
|
354
|
+
id: 'date.birthdate',
|
|
355
|
+
label: 'Birth date',
|
|
356
|
+
group: 'Person',
|
|
357
|
+
options: between(18, 80),
|
|
358
|
+
make: ({ faker, options }) => faker.date.birthdate({
|
|
359
|
+
mode: 'age',
|
|
360
|
+
min: number(options, 'min', 18),
|
|
361
|
+
max: number(options, 'max', 80),
|
|
362
|
+
}),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
id: 'location.streetAddress',
|
|
366
|
+
label: 'Street address',
|
|
367
|
+
group: 'Location',
|
|
368
|
+
make: ({ faker }) => faker.location.streetAddress(),
|
|
369
|
+
},
|
|
370
|
+
{ id: 'location.city', label: 'City', group: 'Location', make: ({ faker }) => faker.location.city() },
|
|
371
|
+
{
|
|
372
|
+
id: 'location.state',
|
|
373
|
+
label: 'State',
|
|
374
|
+
group: 'Location',
|
|
375
|
+
make: ({ faker }) => faker.location.state(),
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
id: 'location.zipCode',
|
|
379
|
+
label: 'Postcode',
|
|
380
|
+
group: 'Location',
|
|
381
|
+
make: ({ faker }) => faker.location.zipCode(),
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
id: 'location.country',
|
|
385
|
+
label: 'Country',
|
|
386
|
+
group: 'Location',
|
|
387
|
+
make: ({ faker }) => faker.location.country(),
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
id: 'location.countryCode',
|
|
391
|
+
label: 'Country code',
|
|
392
|
+
group: 'Location',
|
|
393
|
+
make: ({ faker }) => faker.location.countryCode(),
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
id: 'location.latitude',
|
|
397
|
+
label: 'Latitude',
|
|
398
|
+
group: 'Location',
|
|
399
|
+
make: ({ faker }) => faker.location.latitude(),
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
id: 'location.longitude',
|
|
403
|
+
label: 'Longitude',
|
|
404
|
+
group: 'Location',
|
|
405
|
+
make: ({ faker }) => faker.location.longitude(),
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
id: 'location.timeZone',
|
|
409
|
+
label: 'Time zone',
|
|
410
|
+
group: 'Location',
|
|
411
|
+
make: ({ faker }) => faker.location.timeZone(),
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'company.name',
|
|
415
|
+
label: 'Company name',
|
|
416
|
+
group: 'Business',
|
|
417
|
+
make: ({ faker }) => faker.company.name(),
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: 'company.catchPhrase',
|
|
421
|
+
label: 'Catch phrase',
|
|
422
|
+
group: 'Business',
|
|
423
|
+
make: ({ faker }) => faker.company.catchPhrase(),
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: 'commerce.productName',
|
|
427
|
+
label: 'Product name',
|
|
428
|
+
group: 'Business',
|
|
429
|
+
make: ({ faker }) => faker.commerce.productName(),
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
id: 'commerce.department',
|
|
433
|
+
label: 'Department',
|
|
434
|
+
group: 'Business',
|
|
435
|
+
make: ({ faker }) => faker.commerce.department(),
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
id: 'commerce.price',
|
|
439
|
+
label: 'Price',
|
|
440
|
+
group: 'Business',
|
|
441
|
+
options: between(1, 1000),
|
|
442
|
+
make: ({ faker, options }) => faker.commerce.price({ min: number(options, 'min', 1), max: number(options, 'max', 1000) }),
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
id: 'finance.currencyCode',
|
|
446
|
+
label: 'Currency code',
|
|
447
|
+
group: 'Business',
|
|
448
|
+
make: ({ faker }) => faker.finance.currencyCode(),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
id: 'finance.accountNumber',
|
|
452
|
+
label: 'Account number',
|
|
453
|
+
group: 'Business',
|
|
454
|
+
make: ({ faker }) => faker.finance.accountNumber(),
|
|
455
|
+
},
|
|
456
|
+
{ id: 'internet.url', label: 'URL', group: 'Internet', make: ({ faker }) => faker.internet.url() },
|
|
457
|
+
{
|
|
458
|
+
id: 'internet.domainName',
|
|
459
|
+
label: 'Domain name',
|
|
460
|
+
group: 'Internet',
|
|
461
|
+
make: ({ faker }) => faker.internet.domainName(),
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
id: 'internet.ipv4',
|
|
465
|
+
label: 'IPv4 address',
|
|
466
|
+
group: 'Internet',
|
|
467
|
+
make: ({ faker }) => faker.internet.ipv4(),
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
id: 'internet.ipv6',
|
|
471
|
+
label: 'IPv6 address',
|
|
472
|
+
group: 'Internet',
|
|
473
|
+
make: ({ faker }) => faker.internet.ipv6(),
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
id: 'internet.mac',
|
|
477
|
+
label: 'MAC address',
|
|
478
|
+
group: 'Internet',
|
|
479
|
+
make: ({ faker }) => faker.internet.mac(),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
id: 'internet.userAgent',
|
|
483
|
+
label: 'User agent',
|
|
484
|
+
group: 'Internet',
|
|
485
|
+
make: ({ faker }) => faker.internet.userAgent(),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
id: 'image.avatar',
|
|
489
|
+
label: 'Avatar URL',
|
|
490
|
+
group: 'Internet',
|
|
491
|
+
make: ({ faker }) => faker.image.avatar(),
|
|
492
|
+
},
|
|
493
|
+
{ id: 'image.url', label: 'Image URL', group: 'Internet', make: ({ faker }) => faker.image.url() },
|
|
494
|
+
{
|
|
495
|
+
id: 'color.rgb',
|
|
496
|
+
label: 'Colour (#hex)',
|
|
497
|
+
group: 'Internet',
|
|
498
|
+
make: ({ faker }) => faker.color.rgb(),
|
|
499
|
+
},
|
|
500
|
+
{ id: 'lorem.word', label: 'Word', group: 'Text', make: ({ faker }) => faker.lorem.word() },
|
|
501
|
+
{
|
|
502
|
+
id: 'lorem.words',
|
|
503
|
+
label: 'Words',
|
|
504
|
+
group: 'Text',
|
|
505
|
+
options: [{ name: 'count', label: 'words', type: 'number', default: 3 }],
|
|
506
|
+
make: ({ faker, options }) => faker.lorem.words(number(options, 'count', 3)),
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
id: 'lorem.sentence',
|
|
510
|
+
label: 'Sentence',
|
|
511
|
+
group: 'Text',
|
|
512
|
+
make: ({ faker }) => faker.lorem.sentence(),
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
id: 'lorem.paragraphs',
|
|
516
|
+
label: 'Paragraphs',
|
|
517
|
+
group: 'Text',
|
|
518
|
+
options: [{ name: 'count', label: 'paragraphs', type: 'number', default: 1 }],
|
|
519
|
+
make: ({ faker, options }) => faker.lorem.paragraphs(number(options, 'count', 1)),
|
|
520
|
+
},
|
|
521
|
+
{ id: 'lorem.slug', label: 'Slug', group: 'Text', make: ({ faker }) => faker.lorem.slug() },
|
|
522
|
+
{
|
|
523
|
+
id: 'string.alpha',
|
|
524
|
+
label: 'Letters',
|
|
525
|
+
group: 'Text',
|
|
526
|
+
options: lengthOption(8),
|
|
527
|
+
make: ({ faker, options }) => faker.string.alpha(number(options, 'length', 8)),
|
|
528
|
+
},
|
|
529
|
+
{
|
|
530
|
+
id: 'string.alphanumeric',
|
|
531
|
+
label: 'Letters and digits',
|
|
532
|
+
group: 'Text',
|
|
533
|
+
options: lengthOption(10),
|
|
534
|
+
make: ({ faker, options }) => faker.string.alphanumeric(number(options, 'length', 10)),
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
id: 'string.numeric',
|
|
538
|
+
label: 'Digits',
|
|
539
|
+
group: 'Text',
|
|
540
|
+
options: lengthOption(6),
|
|
541
|
+
make: ({ faker, options }) => faker.string.numeric(number(options, 'length', 6)),
|
|
542
|
+
},
|
|
543
|
+
{ id: 'string.uuid', label: 'UUID', group: 'Text', make: ({ faker }) => faker.string.uuid() },
|
|
544
|
+
{ id: 'string.nanoid', label: 'Nano ID', group: 'Text', make: ({ faker }) => faker.string.nanoid() },
|
|
545
|
+
{
|
|
546
|
+
id: 'json.object',
|
|
547
|
+
label: 'JSON object',
|
|
548
|
+
group: 'Text',
|
|
549
|
+
options: [{ name: 'keys', label: 'keys', type: 'number', default: 3 }],
|
|
550
|
+
make: ({ faker, options }) => Object.fromEntries(Array.from({ length: number(options, 'keys', 3) }, () => [
|
|
551
|
+
faker.lorem.word(),
|
|
552
|
+
faker.lorem.words(2),
|
|
553
|
+
])),
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: 'number.int',
|
|
557
|
+
label: 'Whole number',
|
|
558
|
+
group: 'Numbers',
|
|
559
|
+
options: between(0, 1000),
|
|
560
|
+
make: ({ faker, options }) => faker.number.int({ min: number(options, 'min', 0), max: number(options, 'max', 1000) }),
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'number.float',
|
|
564
|
+
label: 'Decimal number',
|
|
565
|
+
group: 'Numbers',
|
|
566
|
+
options: [
|
|
567
|
+
...between(0, 1000),
|
|
568
|
+
{ name: 'fractionDigits', label: 'decimals', type: 'number', default: 2 },
|
|
569
|
+
],
|
|
570
|
+
make: ({ faker, options }) => faker.number.float({
|
|
571
|
+
min: number(options, 'min', 0),
|
|
572
|
+
max: number(options, 'max', 1000),
|
|
573
|
+
fractionDigits: number(options, 'fractionDigits', 2),
|
|
574
|
+
}),
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: 'datatype.boolean',
|
|
578
|
+
label: 'True or false',
|
|
579
|
+
group: 'Numbers',
|
|
580
|
+
options: [{ name: 'percent', label: '% true', type: 'number', default: 50 }],
|
|
581
|
+
make: ({ faker, options }) => faker.datatype.boolean(number(options, 'percent', 50) / 100),
|
|
582
|
+
},
|
|
583
|
+
{
|
|
584
|
+
id: 'date.past',
|
|
585
|
+
label: 'Past date',
|
|
586
|
+
group: 'Dates',
|
|
587
|
+
options: [{ name: 'years', label: 'years back', type: 'number', default: 1 }],
|
|
588
|
+
make: ({ faker, options }) => faker.date.past({ years: number(options, 'years', 1) }),
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
id: 'date.recent',
|
|
592
|
+
label: 'Recent date',
|
|
593
|
+
group: 'Dates',
|
|
594
|
+
options: [{ name: 'days', label: 'days back', type: 'number', default: 30 }],
|
|
595
|
+
make: ({ faker, options }) => faker.date.recent({ days: number(options, 'days', 30) }),
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
id: 'date.future',
|
|
599
|
+
label: 'Future date',
|
|
600
|
+
group: 'Dates',
|
|
601
|
+
options: [{ name: 'years', label: 'years ahead', type: 'number', default: 1 }],
|
|
602
|
+
make: ({ faker, options }) => faker.date.future({ years: number(options, 'years', 1) }),
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
id: 'date.between',
|
|
606
|
+
label: 'Date between',
|
|
607
|
+
group: 'Dates',
|
|
608
|
+
options: [
|
|
609
|
+
{ name: 'from', label: 'from', type: 'date', default: '2020-01-01' },
|
|
610
|
+
{ name: 'to', label: 'to', type: 'date', default: '2025-12-31' },
|
|
611
|
+
],
|
|
612
|
+
make: ({ faker, options }) => faker.date.between({
|
|
613
|
+
from: date(options, 'from', '2020-01-01'),
|
|
614
|
+
to: date(options, 'to', '2025-12-31'),
|
|
615
|
+
}),
|
|
616
|
+
},
|
|
617
|
+
];
|
|
618
|
+
const byId = new Map(definitions.map((generator) => [generator.id, generator]));
|
|
619
|
+
/** Every generator, for the picker: no functions, so it can be sent as JSON. */
|
|
620
|
+
export const generators = definitions.map(({ id, label, group, options }) => ({
|
|
621
|
+
id,
|
|
622
|
+
label,
|
|
623
|
+
group,
|
|
624
|
+
...(options ? { options } : {}),
|
|
625
|
+
}));
|
|
626
|
+
const plan = (generator, options) => options ? { generator, options } : { generator };
|
|
627
|
+
// Column names (with _ and case ignored) and what usually goes in them; first match wins.
|
|
628
|
+
const byName = [
|
|
629
|
+
[/^(e?mail|emailaddress|.*email)$/, plan('internet.email')],
|
|
630
|
+
[/^(first|given)name$|^fname$/, plan('person.firstName')],
|
|
631
|
+
[/^(last|family|sur)name$|^lname$/, plan('person.lastName')],
|
|
632
|
+
[/^(full|display|contact|customer)?name$/, plan('person.fullName')],
|
|
633
|
+
[/^(user|login|nick|screen)name$|^handle$/, plan('internet.username')],
|
|
634
|
+
[/phone|mobile|^tel/, plan('phone.number')],
|
|
635
|
+
[/^(job)?title$/, plan('lorem.words', { count: 4 })],
|
|
636
|
+
[/jobtitle|position|role$/, plan('person.jobTitle')],
|
|
637
|
+
[/^(gender|sex)$/, plan('person.sex')],
|
|
638
|
+
[/^(bio|about)$/, plan('person.bio')],
|
|
639
|
+
[/^(dob|birth(date|day)|dateofbirth)$/, plan('date.birthdate')],
|
|
640
|
+
[/(street|address(line)?\d?)$/, plan('location.streetAddress')],
|
|
641
|
+
[/city|town/, plan('location.city')],
|
|
642
|
+
[/^(state|province|region|county)$/, plan('location.state')],
|
|
643
|
+
[/zip|post(al)?code/, plan('location.zipCode')],
|
|
644
|
+
[/countrycode/, plan('location.countryCode')],
|
|
645
|
+
[/country/, plan('location.country')],
|
|
646
|
+
[/^lat(itude)?$/, plan('location.latitude')],
|
|
647
|
+
[/^(lng|lon|long|longitude)$/, plan('location.longitude')],
|
|
648
|
+
[/timezone|^tz$/, plan('location.timeZone')],
|
|
649
|
+
[/company|organi[sz]ation|employer|^org$/, plan('company.name')],
|
|
650
|
+
[/product/, plan('commerce.productName')],
|
|
651
|
+
[/department/, plan('commerce.department')],
|
|
652
|
+
[/price|amount|cost|total|balance|fee/, plan('commerce.price')],
|
|
653
|
+
[/currency/, plan('finance.currencyCode')],
|
|
654
|
+
[/avatar/, plan('image.avatar')],
|
|
655
|
+
[/image|photo|picture|thumbnail/, plan('image.url')],
|
|
656
|
+
[/url|website|link|homepage/, plan('internet.url')],
|
|
657
|
+
[/domain/, plan('internet.domainName')],
|
|
658
|
+
[/ip(address)?$/, plan('internet.ipv4')],
|
|
659
|
+
[/useragent/, plan('internet.userAgent')],
|
|
660
|
+
[/colou?r/, plan('color.rgb')],
|
|
661
|
+
[/slug/, plan('lorem.slug')],
|
|
662
|
+
[/description|notes?$|comment|body|content|summary|message/, plan('lorem.paragraphs')],
|
|
663
|
+
[/^(sku|code|reference|ref)$/, plan('string.alphanumeric', { length: 10 })],
|
|
664
|
+
];
|
|
665
|
+
const baseType = (type) => type
|
|
666
|
+
.replace(/\[\]$/, '')
|
|
667
|
+
.replace(/\(.*\)/, '')
|
|
668
|
+
.trim();
|
|
669
|
+
/** The length limit of a varchar(n) or char(n) column. */
|
|
670
|
+
export const maxLengthOf = (type) => {
|
|
671
|
+
const match = /^(character varying|varchar|character|char|bpchar)\((\d+)\)/.exec(type);
|
|
672
|
+
return match ? Number(match[2]) : undefined;
|
|
673
|
+
};
|
|
674
|
+
/** A generator that suits a column's type alone. */
|
|
675
|
+
function byType(type, allowedValues) {
|
|
676
|
+
if (allowedValues?.length)
|
|
677
|
+
return plan('oneOf', { values: allowedValues.join('\n') });
|
|
678
|
+
const base = baseType(type);
|
|
679
|
+
if (base === 'boolean')
|
|
680
|
+
return plan('datatype.boolean');
|
|
681
|
+
if (base === 'smallint')
|
|
682
|
+
return plan('number.int', { min: 0, max: 1000 });
|
|
683
|
+
if (base === 'integer' || base === 'bigint')
|
|
684
|
+
return plan('number.int', { min: 0, max: 100000 });
|
|
685
|
+
if (['numeric', 'real', 'double precision', 'money'].includes(base))
|
|
686
|
+
return plan('number.float');
|
|
687
|
+
if (base.startsWith('timestamp') || base === 'date')
|
|
688
|
+
return plan('date.past');
|
|
689
|
+
if (base.startsWith('time'))
|
|
690
|
+
return plan('date.recent');
|
|
691
|
+
if (base === 'uuid')
|
|
692
|
+
return plan('string.uuid');
|
|
693
|
+
if (base === 'inet' || base === 'cidr')
|
|
694
|
+
return plan('internet.ipv4');
|
|
695
|
+
if (base === 'macaddr')
|
|
696
|
+
return plan('internet.mac');
|
|
697
|
+
if (base === 'json' || base === 'jsonb')
|
|
698
|
+
return plan('json.object');
|
|
699
|
+
if (base === 'character' || base === 'char' || base === 'bpchar') {
|
|
700
|
+
return plan('string.alpha', { length: maxLengthOf(type) ?? 1 });
|
|
701
|
+
}
|
|
702
|
+
if (base === 'text' || base === 'character varying' || base === 'varchar' || base === 'citext') {
|
|
703
|
+
return plan('lorem.words');
|
|
704
|
+
}
|
|
705
|
+
return plan('null');
|
|
706
|
+
}
|
|
707
|
+
const isNumericType = (type) => ['smallint', 'integer', 'bigint', 'numeric', 'real', 'double precision'].includes(baseType(type));
|
|
708
|
+
const isTextType = (type) => ['text', 'character varying', 'varchar', 'citext', 'character', 'char'].includes(baseType(type));
|
|
709
|
+
/** The generator a column most likely wants, from its keys, default, type and name. */
|
|
710
|
+
export function suggestPlan(column,
|
|
711
|
+
// False for a plan that always writes a value, e.g. for a column given conditions.
|
|
712
|
+
{ keepDefault = true } = {}) {
|
|
713
|
+
if (keepDefault && (column.generated || column.identity))
|
|
714
|
+
return plan('default');
|
|
715
|
+
// Serials, gen_random_uuid(), now() and the like fill themselves in.
|
|
716
|
+
if (keepDefault &&
|
|
717
|
+
column.default !== null &&
|
|
718
|
+
(column.primaryKey || /nextval\(|uuid|now\(\)|current_/i.test(column.default))) {
|
|
719
|
+
return plan('default');
|
|
720
|
+
}
|
|
721
|
+
if (column.references)
|
|
722
|
+
return { generator: 'reference', ...(column.unique ? { unique: true } : {}) };
|
|
723
|
+
const unique = column.primaryKey || column.unique;
|
|
724
|
+
const fromType = byType(column.type, column.allowedValues);
|
|
725
|
+
let chosen = fromType;
|
|
726
|
+
if (isTextType(column.type) && !column.allowedValues) {
|
|
727
|
+
const name = column.name.toLowerCase().replace(/[_\s-]/g, '');
|
|
728
|
+
const match = byName.find(([pattern]) => pattern.test(name));
|
|
729
|
+
if (match)
|
|
730
|
+
chosen = match[1];
|
|
731
|
+
// char(2) and the like hold codes, not names.
|
|
732
|
+
const limit = maxLengthOf(column.type);
|
|
733
|
+
if (limit !== undefined && limit <= 3 && chosen.generator === 'location.country') {
|
|
734
|
+
chosen = plan('location.countryCode');
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (isNumericType(column.type) &&
|
|
738
|
+
/price|amount|cost|total|balance|fee/i.test(column.name)) {
|
|
739
|
+
chosen = plan('number.float', { min: 1, max: 1000, fractionDigits: 2 });
|
|
740
|
+
}
|
|
741
|
+
else if (keepDefault &&
|
|
742
|
+
/^(created|updated|modified|deleted)(_?at|_?on)?$/i.test(column.name) &&
|
|
743
|
+
column.default !== null) {
|
|
744
|
+
chosen = plan('default');
|
|
745
|
+
}
|
|
746
|
+
if (isArrayType(column.type) && !wholeArrayGenerators.has(chosen.generator)) {
|
|
747
|
+
// An array of allowed values picks several of them; an array of text holds single words.
|
|
748
|
+
if (chosen.generator === 'oneOf')
|
|
749
|
+
chosen = { ...chosen, generator: 'someOf' };
|
|
750
|
+
else if (chosen.generator === 'lorem.words')
|
|
751
|
+
chosen = { ...plan('lorem.word'), items: DEFAULT_ITEMS };
|
|
752
|
+
else
|
|
753
|
+
chosen = { ...chosen, items: DEFAULT_ITEMS };
|
|
754
|
+
}
|
|
755
|
+
// A nullable column with a default nobody would pick (e.g. a constant) keeps its default.
|
|
756
|
+
if (keepDefault && chosen.generator === 'null' && !column.nullable && column.default !== null)
|
|
757
|
+
chosen = plan('default');
|
|
758
|
+
if (unique && chosen.generator !== 'default') {
|
|
759
|
+
// Mostly-unique generators for keys that must not repeat.
|
|
760
|
+
if (chosen.generator === 'number.int')
|
|
761
|
+
chosen = plan('sequence');
|
|
762
|
+
return { ...chosen, unique: true };
|
|
763
|
+
}
|
|
764
|
+
return chosen;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* The values of a one-column `CHECK (status IN ('a', 'b'))`, which Postgres shows as
|
|
768
|
+
* `CHECK ((status = ANY (ARRAY['a'::text, 'b'::text])))`; null for any other check.
|
|
769
|
+
*/
|
|
770
|
+
export function checkListOf(definition) {
|
|
771
|
+
const match = definition &&
|
|
772
|
+
/^CHECK \(+[^()]*?(?:\)::\w+)? = ANY \(+ARRAY\[(.*)\](?:\)::[\w\s[\]]+)?\)+$/.exec(definition);
|
|
773
|
+
if (!match || / (AND|OR) /.test(definition))
|
|
774
|
+
return null;
|
|
775
|
+
const values = [...match[1].matchAll(/'((?:[^']|'')*)'(?:::[\w\s]+)?/g)].map((item) => item[1].replaceAll("''", "'"));
|
|
776
|
+
return values.length > 0 ? values : null;
|
|
777
|
+
}
|
|
778
|
+
/** The table's columns with what the seeder needs to know about each, and a suggested plan. */
|
|
779
|
+
export async function describeSeedColumns(session, table) {
|
|
780
|
+
const columns = await listColumns(session, table);
|
|
781
|
+
const extra = await session.rows(`SELECT a.attname AS name, a.attidentity AS identity, a.attgenerated AS generated,
|
|
782
|
+
(SELECT json_agg(e.enumlabel ORDER BY e.enumsortorder) FROM pg_catalog.pg_enum e
|
|
783
|
+
WHERE e.enumtypid IN (a.atttypid, t.typelem, t.typbasetype))::text AS enum_values,
|
|
784
|
+
(SELECT pg_catalog.pg_get_constraintdef(c.oid) FROM pg_catalog.pg_constraint c
|
|
785
|
+
WHERE c.conrelid = a.attrelid AND c.contype = 'c' AND c.conkey = ARRAY[a.attnum]
|
|
786
|
+
AND pg_catalog.pg_get_constraintdef(c.oid) LIKE '%= ANY (%ARRAY[%'
|
|
787
|
+
ORDER BY c.conname LIMIT 1) AS check_list
|
|
788
|
+
FROM pg_catalog.pg_attribute a
|
|
789
|
+
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
790
|
+
WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped`, [qualified(table)]);
|
|
791
|
+
const found = new Map(extra.map((row) => [row.name, row]));
|
|
792
|
+
return columns.map((column) => {
|
|
793
|
+
const row = found.get(column.name);
|
|
794
|
+
const seedColumn = {
|
|
795
|
+
...column,
|
|
796
|
+
identity: row?.identity === 'a' ? 'always' : row?.identity === 'd' ? 'default' : null,
|
|
797
|
+
generated: !!row?.generated,
|
|
798
|
+
allowedValues: row?.enum_values
|
|
799
|
+
? JSON.parse(row.enum_values)
|
|
800
|
+
: checkListOf(row?.check_list ?? null),
|
|
801
|
+
};
|
|
802
|
+
return {
|
|
803
|
+
...seedColumn,
|
|
804
|
+
suggestion: suggestPlan(seedColumn),
|
|
805
|
+
valueSuggestion: suggestPlan(seedColumn, { keepDefault: false }),
|
|
806
|
+
};
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const pad = (value, size = 2) => String(value).padStart(size, '0');
|
|
810
|
+
/** A generated value as the text Postgres reads for a column of this type; null for NULL. */
|
|
811
|
+
export function toCell(value, type) {
|
|
812
|
+
if (value === null || value === undefined)
|
|
813
|
+
return null;
|
|
814
|
+
if (isArrayType(type)) {
|
|
815
|
+
const element = type.slice(0, -2);
|
|
816
|
+
// Text that already reads as an array, such as a fixed value of '{a,b}', is kept as it is.
|
|
817
|
+
if (typeof value === 'string' && value.trimStart().startsWith('{'))
|
|
818
|
+
return value;
|
|
819
|
+
const items = Array.isArray(value) ? value : [value];
|
|
820
|
+
return `{${items.map((item) => arrayItem(toCell(item, element))).join(',')}}`;
|
|
821
|
+
}
|
|
822
|
+
const base = baseType(type);
|
|
823
|
+
let cell;
|
|
824
|
+
if (value instanceof Date) {
|
|
825
|
+
if (base === 'date') {
|
|
826
|
+
cell = `${value.getUTCFullYear()}-${pad(value.getUTCMonth() + 1)}-${pad(value.getUTCDate())}`;
|
|
827
|
+
}
|
|
828
|
+
else if (base.startsWith('time') && !base.startsWith('timestamp')) {
|
|
829
|
+
cell = `${pad(value.getUTCHours())}:${pad(value.getUTCMinutes())}:${pad(value.getUTCSeconds())}`;
|
|
830
|
+
}
|
|
831
|
+
else
|
|
832
|
+
cell = value.toISOString();
|
|
833
|
+
}
|
|
834
|
+
else if (typeof value === 'object')
|
|
835
|
+
cell = JSON.stringify(value);
|
|
836
|
+
else
|
|
837
|
+
cell = String(value);
|
|
838
|
+
const limit = maxLengthOf(type);
|
|
839
|
+
return limit !== undefined && cell.length > limit ? cell.slice(0, limit) : cell;
|
|
840
|
+
}
|
|
841
|
+
/** One value of an array literal: NULL, or quoted so commas, braces and quotes survive. */
|
|
842
|
+
const arrayItem = (cell) => cell === null ? 'NULL' : `"${cell.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
|
843
|
+
/** How many values an array plan asks for, kept within sense. */
|
|
844
|
+
export function itemsOf(chosen) {
|
|
845
|
+
const clamp = (value, fallback) => Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), 0), MAX_ITEMS) : fallback;
|
|
846
|
+
const min = clamp(chosen.items?.min, DEFAULT_ITEMS.min);
|
|
847
|
+
return { min, max: Math.max(min, clamp(chosen.items?.max, Math.max(min, DEFAULT_ITEMS.max))) };
|
|
848
|
+
}
|
|
849
|
+
/** Rows made for each row of the "each" links when the request does not say. */
|
|
850
|
+
export const DEFAULT_PER_ROW = { min: 1, max: 1 };
|
|
851
|
+
/** How many rows each row of the "each" links gets, kept within sense. */
|
|
852
|
+
export function perRowOf(request) {
|
|
853
|
+
const clamp = (value, fallback) => Number.isFinite(value) ? Math.min(Math.max(Math.floor(value), 0), MAX_SEED_ROWS) : fallback;
|
|
854
|
+
const min = clamp(request.perRow?.min, DEFAULT_PER_ROW.min);
|
|
855
|
+
return { min, max: Math.max(min, clamp(request.perRow?.max, min)) };
|
|
856
|
+
}
|
|
857
|
+
// Thrown by makeCell when a unique column has no new value for this row and rows may be skipped.
|
|
858
|
+
class OutOfUniqueValues extends Error {
|
|
859
|
+
}
|
|
860
|
+
const tableName = (table) => `${table.schema}.${table.name}`;
|
|
861
|
+
const columnNames = (names) => names.map((name) => `"${name}"`).join(', ');
|
|
862
|
+
/** The link a plan reads `column` of, which then loads that column. */
|
|
863
|
+
function linkFor(links, name, link, column) {
|
|
864
|
+
const found = links.get(link);
|
|
865
|
+
if (!found)
|
|
866
|
+
throw new SeedError(`"${name}" reads the link "${link}", and there is no such link`);
|
|
867
|
+
if (!column)
|
|
868
|
+
throw new SeedError(`"${name}": pick a column of ${tableName(found.table)}`);
|
|
869
|
+
if (!found.columns.has(column))
|
|
870
|
+
found.columns.set(column, found.columns.size);
|
|
871
|
+
return found;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Checks one plan against its column; a `reference` becomes a read of its foreign key's link, at
|
|
875
|
+
* the column the key points at.
|
|
876
|
+
*/
|
|
877
|
+
function preparePlan(column, chosen, keys, links) {
|
|
878
|
+
const generator = byId.get(chosen.generator);
|
|
879
|
+
if (!generator)
|
|
880
|
+
throw new SeedError(`"${column.name}": no generator "${chosen.generator}"`);
|
|
881
|
+
if (generator.id === 'null' && !column.nullable) {
|
|
882
|
+
throw new SeedError(`"${column.name}" cannot be NULL`);
|
|
883
|
+
}
|
|
884
|
+
let resolved = chosen;
|
|
885
|
+
if (generator.id === 'reference') {
|
|
886
|
+
const key = keys.find((each) => each.fromColumns.includes(column.name));
|
|
887
|
+
if (!key)
|
|
888
|
+
throw new SeedError(`"${column.name}" has no foreign key to take values from`);
|
|
889
|
+
const target = key.toColumns[key.fromColumns.indexOf(column.name)];
|
|
890
|
+
resolved = { ...chosen, options: { link: key.name, column: target } };
|
|
891
|
+
}
|
|
892
|
+
const options = resolved.options ?? {};
|
|
893
|
+
if (linkedGenerators.has(generator.id)) {
|
|
894
|
+
const link = linkFor(links, column.name, text(options, 'link'), text(options, 'column'));
|
|
895
|
+
// A column that must be unique takes a row of the link no other row has.
|
|
896
|
+
if (chosen.unique && link.mode === 'random')
|
|
897
|
+
link.unique = true;
|
|
898
|
+
}
|
|
899
|
+
if (generator.id === 'template') {
|
|
900
|
+
for (const [, link, name] of text(options, 'template').matchAll(linkReference))
|
|
901
|
+
linkFor(links, column.name, link, name);
|
|
902
|
+
}
|
|
903
|
+
return { generator, plan: resolved };
|
|
904
|
+
}
|
|
905
|
+
/** Reads the columns plans use of a link's table: a sample to pick from, or every row for "each". */
|
|
906
|
+
async function loadLink(session, link, columns,
|
|
907
|
+
// Rows that need a row of their own when the link is unique.
|
|
908
|
+
count) {
|
|
909
|
+
const names = [...link.columns.keys()];
|
|
910
|
+
const read = names.map((name) => `s.${quoteIdentifier(name)}`);
|
|
911
|
+
// A foreign key can only point at a row whose key columns are all set.
|
|
912
|
+
const where = link.key
|
|
913
|
+
? ` WHERE ${link.key.toColumns.map((name) => `s.${quoteIdentifier(name)} IS NOT NULL`).join(' AND ')}`
|
|
914
|
+
: '';
|
|
915
|
+
const list = read.length > 0 ? read.map((each) => `${each}::text`).join(', ') : 'NULL';
|
|
916
|
+
const sql = link.mode === 'each'
|
|
917
|
+
? `SELECT ${list} FROM ${qualified(link.table)} s${where}${read.length > 0 ? ` ORDER BY ${read.join(', ')}` : ''} LIMIT ${MAX_COMBINATIONS + 1}`
|
|
918
|
+
: `SELECT DISTINCT ${list} FROM ${qualified(link.table)} s${where} LIMIT ${REFERENCE_SAMPLE}`;
|
|
919
|
+
try {
|
|
920
|
+
link.rows = (await session.run(sql)).rows;
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
throw new SeedError(`the link "${link.name}" to ${tableName(link.table)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
924
|
+
}
|
|
925
|
+
const from = link.key ? columnNames(link.key.fromColumns) : `the link "${link.name}"`;
|
|
926
|
+
if (link.rows.length === 0) {
|
|
927
|
+
if (link.mode === 'each') {
|
|
928
|
+
throw new SeedError(`${tableName(link.table)} has no rows to make rows for: seed it first`);
|
|
929
|
+
}
|
|
930
|
+
const nullable = link.key?.fromColumns.every((name) => columns.find((each) => each.name === name)?.nullable) ??
|
|
931
|
+
false;
|
|
932
|
+
if (!nullable) {
|
|
933
|
+
throw new SeedError(`${from} points at ${tableName(link.table)}, which has no rows yet: seed that table first`);
|
|
934
|
+
}
|
|
935
|
+
link.rows = [names.map(() => null)];
|
|
936
|
+
}
|
|
937
|
+
if (link.unique && link.rows.length < count) {
|
|
938
|
+
throw new SeedError(`${from} must be unique but ${tableName(link.table)} has only ${link.rows.length} rows to point at`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Checks every plan against the table, loads the links they read, and puts the columns in an order
|
|
943
|
+
* where each comes after the columns its rules and templates read.
|
|
944
|
+
*/
|
|
945
|
+
async function prepare(session, table, columns, request) {
|
|
946
|
+
for (const name of Object.keys(request.columns)) {
|
|
947
|
+
if (!columns.some((column) => column.name === name))
|
|
948
|
+
throw new SeedError(`no column "${name}"`);
|
|
949
|
+
}
|
|
950
|
+
const keys = (await listForeignKeys(session, table)).outgoing;
|
|
951
|
+
const links = new Map();
|
|
952
|
+
const newLink = (name, to, key) => ({
|
|
953
|
+
name,
|
|
954
|
+
table: to,
|
|
955
|
+
mode: 'random',
|
|
956
|
+
unique: false,
|
|
957
|
+
...(key ? { key } : {}),
|
|
958
|
+
columns: new Map(),
|
|
959
|
+
rows: [],
|
|
960
|
+
used: 0,
|
|
961
|
+
});
|
|
962
|
+
for (const key of keys)
|
|
963
|
+
links.set(key.name, newLink(key.name, key.to, key));
|
|
964
|
+
for (const asked of request.links ?? []) {
|
|
965
|
+
let link = links.get(asked.name);
|
|
966
|
+
if (link && !link.key)
|
|
967
|
+
throw new SeedError(`there are two links called "${asked.name}"`);
|
|
968
|
+
if (!link) {
|
|
969
|
+
if (!asked.schema || !asked.table) {
|
|
970
|
+
throw new SeedError(`the link "${asked.name}" needs a table`);
|
|
971
|
+
}
|
|
972
|
+
link = newLink(asked.name, { schema: asked.schema, name: asked.table });
|
|
973
|
+
links.set(asked.name, link);
|
|
974
|
+
}
|
|
975
|
+
link.mode = asked.mode ?? 'random';
|
|
976
|
+
link.unique = link.mode === 'random' && asked.unique === true;
|
|
977
|
+
}
|
|
978
|
+
const each = [...links.values()].filter((link) => link.mode === 'each');
|
|
979
|
+
const prepared = [];
|
|
980
|
+
for (const column of columns) {
|
|
981
|
+
const chosen = request.columns[column.name] ?? { generator: 'default' };
|
|
982
|
+
const rules = chosen.rules ?? [];
|
|
983
|
+
if (chosen.generator === 'default' && rules.length === 0)
|
|
984
|
+
continue;
|
|
985
|
+
if (column.generated) {
|
|
986
|
+
throw new SeedError(`"${column.name}" is a generated column, so it keeps its default`);
|
|
987
|
+
}
|
|
988
|
+
if (chosen.generator === 'default' || rules.some((rule) => rule.then.generator === 'default')) {
|
|
989
|
+
throw new SeedError(`"${column.name}" has conditions, so every case needs a generator: pick NULL or a fixed value instead of the column default`);
|
|
990
|
+
}
|
|
991
|
+
const base = preparePlan(column, chosen, keys, links);
|
|
992
|
+
const prepRules = [];
|
|
993
|
+
for (const rule of rules) {
|
|
994
|
+
if (rule.when.column === column.name) {
|
|
995
|
+
throw new SeedError(`"${column.name}" has a condition on itself`);
|
|
996
|
+
}
|
|
997
|
+
prepRules.push({ when: rule.when, use: preparePlan(column, rule.then, keys, links) });
|
|
998
|
+
}
|
|
999
|
+
prepared.push({ column, base, rules: prepRules, seen: new Set() });
|
|
1000
|
+
}
|
|
1001
|
+
// Rows may be skipped, so a unique link may have fewer rows than asked for; with "each" links
|
|
1002
|
+
// the number of rows is not known yet, and running out is found as rows are made.
|
|
1003
|
+
const needed = request.skipDuplicates || each.length > 0 ? 0 : request.count;
|
|
1004
|
+
for (const link of links.values()) {
|
|
1005
|
+
if (link.mode === 'each' || link.columns.size > 0)
|
|
1006
|
+
await loadLink(session, link, columns, needed);
|
|
1007
|
+
}
|
|
1008
|
+
const index = new Map(prepared.map((each, i) => [each.column.name, i]));
|
|
1009
|
+
const order = [];
|
|
1010
|
+
const state = new Map();
|
|
1011
|
+
const visit = (i, path) => {
|
|
1012
|
+
if (state.get(i) === 'done')
|
|
1013
|
+
return;
|
|
1014
|
+
const name = prepared[i].column.name;
|
|
1015
|
+
if (state.get(i) === 'visiting') {
|
|
1016
|
+
throw new SeedError(`these columns depend on each other: ${[...path, name].join(' → ')}`);
|
|
1017
|
+
}
|
|
1018
|
+
state.set(i, 'visiting');
|
|
1019
|
+
for (const needed of dependenciesOf(prepared[i].base.plan)) {
|
|
1020
|
+
const at = index.get(needed);
|
|
1021
|
+
if (at === undefined) {
|
|
1022
|
+
throw new SeedError(columns.some((column) => column.name === needed)
|
|
1023
|
+
? `"${name}" depends on "${needed}", which keeps its column default, so its value is not known: give it a generator`
|
|
1024
|
+
: `"${name}" depends on "${needed}", which is not a column`);
|
|
1025
|
+
}
|
|
1026
|
+
visit(at, [...path, name]);
|
|
1027
|
+
}
|
|
1028
|
+
state.set(i, 'done');
|
|
1029
|
+
order.push(i);
|
|
1030
|
+
};
|
|
1031
|
+
prepared.forEach((_, i) => visit(i, []));
|
|
1032
|
+
return {
|
|
1033
|
+
columns: prepared,
|
|
1034
|
+
order,
|
|
1035
|
+
skipDuplicates: request.skipDuplicates === true,
|
|
1036
|
+
links,
|
|
1037
|
+
each,
|
|
1038
|
+
perRow: perRowOf(request),
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/** How many rows a run makes: the count asked for, or `perRow` for each row of the "each" links. */
|
|
1042
|
+
function layout(faker, plan, count) {
|
|
1043
|
+
if (plan.each.length === 0)
|
|
1044
|
+
return { total: count };
|
|
1045
|
+
const combinations = plan.each.reduce((product, link) => product * link.rows.length, 1);
|
|
1046
|
+
const names = plan.each.map((link) => tableName(link.table)).join(' × ');
|
|
1047
|
+
if (combinations > MAX_COMBINATIONS) {
|
|
1048
|
+
throw new SeedError(`${names} make ${combinations.toLocaleString('en')} combinations, more than ${MAX_COMBINATIONS.toLocaleString('en')}`);
|
|
1049
|
+
}
|
|
1050
|
+
const { min, max } = plan.perRow;
|
|
1051
|
+
const sizes = Array.from({ length: combinations }, () => min === max ? min : faker.number.int({ min, max }));
|
|
1052
|
+
const total = sizes.reduce((sum, size) => sum + size, 0);
|
|
1053
|
+
if (total < 1 || total > MAX_SEED_ROWS) {
|
|
1054
|
+
throw new SeedError(`that makes ${total.toLocaleString('en')} rows for ${combinations.toLocaleString('en')} rows of ${names}; a run makes 1 to ${MAX_SEED_ROWS.toLocaleString('en')}`);
|
|
1055
|
+
}
|
|
1056
|
+
const group = new Int32Array(total);
|
|
1057
|
+
const inGroup = new Int32Array(total);
|
|
1058
|
+
let at = 0;
|
|
1059
|
+
sizes.forEach((size, combination) => {
|
|
1060
|
+
for (let i = 0; i < size; i++, at++) {
|
|
1061
|
+
group[at] = combination;
|
|
1062
|
+
inGroup[at] = i;
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
return { total, group, inGroup };
|
|
1066
|
+
}
|
|
1067
|
+
function makeCell(faker, { column, base, rules, seen }, { index, indexInGroup, linked, row }, skipDuplicates) {
|
|
1068
|
+
const { generator, plan: chosen } = rules.find(({ when }) => matches(when, row[when.column] ?? null))?.use ?? base;
|
|
1069
|
+
const nulls = column.nullable ? Math.min(Math.max(chosen.nulls ?? 0, 0), 100) : 0;
|
|
1070
|
+
if (nulls > 0 && faker.number.float({ min: 0, max: 100 }) < nulls)
|
|
1071
|
+
return null;
|
|
1072
|
+
const context = { faker, options: chosen.options ?? {}, index, indexInGroup, linked, row };
|
|
1073
|
+
for (let attempt = 0;; attempt++) {
|
|
1074
|
+
let value;
|
|
1075
|
+
try {
|
|
1076
|
+
if (isArrayType(column.type) && !wholeArrayGenerators.has(generator.id)) {
|
|
1077
|
+
const { min, max } = itemsOf(chosen);
|
|
1078
|
+
value = Array.from({ length: faker.number.int({ min, max }) }, () => generator.make(context));
|
|
1079
|
+
}
|
|
1080
|
+
else
|
|
1081
|
+
value = generator.make(context);
|
|
1082
|
+
}
|
|
1083
|
+
catch (error) {
|
|
1084
|
+
if (error instanceof SeedError || error instanceof OutOfUniqueValues)
|
|
1085
|
+
throw error;
|
|
1086
|
+
throw new SeedError(`"${column.name}": ${generator.label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1087
|
+
}
|
|
1088
|
+
const cell = toCell(value, column.type);
|
|
1089
|
+
// Unique across the whole column, whichever of its plans made the value; makeRow adds it
|
|
1090
|
+
// to seen once the whole row is made.
|
|
1091
|
+
if (!base.plan.unique || cell === null || !seen.has(cell))
|
|
1092
|
+
return cell;
|
|
1093
|
+
// A linked row's value stays the same however often it is asked for.
|
|
1094
|
+
const linkedValue = linkedGenerators.has(generator.id);
|
|
1095
|
+
if (attempt >= UNIQUE_TRIES || linkedValue) {
|
|
1096
|
+
if (skipDuplicates)
|
|
1097
|
+
throw new OutOfUniqueValues();
|
|
1098
|
+
throw new SeedError(linkedValue
|
|
1099
|
+
? `"${column.name}" must be unique, but its linked rows repeat ${cell}: make the link unique, or skip rows that run out of unique values`
|
|
1100
|
+
: `"${column.name}" must be unique, but ${generator.label} ran out of new values after ${seen.size}: pick one with more variety`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
/** The row of a link a new row reads, picked the first time the row reads the link. */
|
|
1105
|
+
function pickRow(faker, link, skipDuplicates) {
|
|
1106
|
+
if (!link.unique)
|
|
1107
|
+
return faker.number.int({ min: 0, max: link.rows.length - 1 });
|
|
1108
|
+
link.order ??= faker.helpers.shuffle(Array.from(link.rows, (_, i) => i));
|
|
1109
|
+
if (link.used < link.order.length)
|
|
1110
|
+
return link.order[link.used];
|
|
1111
|
+
if (skipDuplicates)
|
|
1112
|
+
throw new OutOfUniqueValues();
|
|
1113
|
+
throw new SeedError(`every row of ${tableName(link.table)} has been picked once, and the link "${link.name}" is unique`);
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* One row, its columns made in dependency order and returned in table order; null when a unique
|
|
1117
|
+
* column ran out of new values and the plan skips such rows.
|
|
1118
|
+
*/
|
|
1119
|
+
function makeRow(faker, plan, rows, index) {
|
|
1120
|
+
const { columns, order, skipDuplicates, links } = plan;
|
|
1121
|
+
const picked = new Map();
|
|
1122
|
+
if (rows.group) {
|
|
1123
|
+
// The row's combination of rows of the "each" links, the last link counting fastest.
|
|
1124
|
+
let combination = rows.group[index];
|
|
1125
|
+
for (const link of plan.each.toReversed()) {
|
|
1126
|
+
picked.set(link, combination % link.rows.length);
|
|
1127
|
+
combination = Math.floor(combination / link.rows.length);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const linked = (name, column) => {
|
|
1131
|
+
const link = links.get(name);
|
|
1132
|
+
if (!link)
|
|
1133
|
+
throw new SeedError(`there is no link "${name}"`);
|
|
1134
|
+
let at = picked.get(link);
|
|
1135
|
+
if (at === undefined) {
|
|
1136
|
+
at = pickRow(faker, link, skipDuplicates);
|
|
1137
|
+
picked.set(link, at);
|
|
1138
|
+
}
|
|
1139
|
+
return link.rows[at][link.columns.get(column)] ?? null;
|
|
1140
|
+
};
|
|
1141
|
+
const row = {};
|
|
1142
|
+
const context = { index, indexInGroup: rows.inGroup?.[index] ?? index, linked, row };
|
|
1143
|
+
const cells = new Array(columns.length);
|
|
1144
|
+
for (const i of order) {
|
|
1145
|
+
let cell;
|
|
1146
|
+
try {
|
|
1147
|
+
cell = makeCell(faker, columns[i], context, skipDuplicates);
|
|
1148
|
+
}
|
|
1149
|
+
catch (error) {
|
|
1150
|
+
if (error instanceof OutOfUniqueValues)
|
|
1151
|
+
return null;
|
|
1152
|
+
throw error;
|
|
1153
|
+
}
|
|
1154
|
+
row[columns[i].column.name] = cell;
|
|
1155
|
+
cells[i] = cell;
|
|
1156
|
+
}
|
|
1157
|
+
// Only a row that is kept uses up its values, and its rows of unique links.
|
|
1158
|
+
columns.forEach(({ base, seen }, i) => {
|
|
1159
|
+
if (base.plan.unique)
|
|
1160
|
+
seen.add(cells[i]);
|
|
1161
|
+
});
|
|
1162
|
+
for (const link of picked.keys())
|
|
1163
|
+
if (link.unique)
|
|
1164
|
+
link.used++;
|
|
1165
|
+
return cells;
|
|
1166
|
+
}
|
|
1167
|
+
/** Makes up to `size` rows from `index` on, leaving out the ones that were skipped. */
|
|
1168
|
+
function makeRows(faker, plan, rows, index, size) {
|
|
1169
|
+
const made = [];
|
|
1170
|
+
for (let i = 0; i < size; i++) {
|
|
1171
|
+
const row = makeRow(faker, plan, rows, index + i);
|
|
1172
|
+
if (row)
|
|
1173
|
+
made.push(row);
|
|
1174
|
+
}
|
|
1175
|
+
return made;
|
|
1176
|
+
}
|
|
1177
|
+
async function fakerFor(request) {
|
|
1178
|
+
const { allFakers } = await import('@faker-js/faker');
|
|
1179
|
+
const faker = allFakers[request.locale ?? 'en'];
|
|
1180
|
+
// Generating is synchronous, so a shared instance is never seeded by two runs at once.
|
|
1181
|
+
faker.seed(request.seed ?? Math.floor(Math.random() * 2 ** 31));
|
|
1182
|
+
return faker;
|
|
1183
|
+
}
|
|
1184
|
+
const literal = (value) => (value === null ? 'NULL' : `'${value.replaceAll("'", "''")}'`);
|
|
1185
|
+
function insertSql(table, prepared, rows, skipConflicts) {
|
|
1186
|
+
const names = prepared.map(({ column }) => quoteIdentifier(column.name)).join(', ');
|
|
1187
|
+
const overriding = prepared.some(({ column }) => column.identity === 'always')
|
|
1188
|
+
? ' OVERRIDING SYSTEM VALUE'
|
|
1189
|
+
: '';
|
|
1190
|
+
const width = prepared.length;
|
|
1191
|
+
const values = Array.from({ length: rows }, (_, row) => `(${prepared.map((_, i) => `$${row * width + i + 1}`).join(', ')})`).join(', ');
|
|
1192
|
+
return `INSERT INTO ${qualified(table)} (${names})${overriding} VALUES ${values}${skipConflicts ? ' ON CONFLICT DO NOTHING' : ''}`;
|
|
1193
|
+
}
|
|
1194
|
+
const checkCount = (count) => {
|
|
1195
|
+
if (!Number.isInteger(count) || count < 1 || count > MAX_SEED_ROWS) {
|
|
1196
|
+
throw new SeedError(`the number of rows is a whole number from 1 to ${MAX_SEED_ROWS.toLocaleString('en')}`);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
const display = (table, prepared, row, skipConflicts) => prepared.length === 0
|
|
1200
|
+
? `INSERT INTO ${qualified(table)} DEFAULT VALUES`
|
|
1201
|
+
: insertSql(table, prepared, 1, skipConflicts).replace(/VALUES \(.*\)/, `VALUES (${row.map(literal).join(', ')})`);
|
|
1202
|
+
/** Generates the first rows a run would insert, without inserting anything. */
|
|
1203
|
+
export async function previewSeed(session, table, request) {
|
|
1204
|
+
checkCount(request.count);
|
|
1205
|
+
const columns = await describeSeedColumns(session, table);
|
|
1206
|
+
const plan = await prepare(session, table, columns, request);
|
|
1207
|
+
const prepared = plan.columns;
|
|
1208
|
+
const faker = await fakerFor(request);
|
|
1209
|
+
const made = layout(faker, plan, request.count);
|
|
1210
|
+
const shown = Math.min(made.total, PREVIEW_ROWS);
|
|
1211
|
+
const rows = makeRows(faker, plan, made, 0, shown);
|
|
1212
|
+
return {
|
|
1213
|
+
columns: prepared.map(({ column }) => column.name),
|
|
1214
|
+
rows,
|
|
1215
|
+
skipped: shown - rows.length,
|
|
1216
|
+
total: made.total,
|
|
1217
|
+
sql: display(table, prepared, rows[0] ?? [], request.skipConflicts),
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
/** Generates and inserts the rows in one transaction: all of them, or none when anything fails. */
|
|
1221
|
+
export async function runSeed(session, table, request) {
|
|
1222
|
+
checkCount(request.count);
|
|
1223
|
+
const started = performance.now();
|
|
1224
|
+
const columns = await describeSeedColumns(session, table);
|
|
1225
|
+
const plan = await prepare(session, table, columns, request);
|
|
1226
|
+
const prepared = plan.columns;
|
|
1227
|
+
const faker = await fakerFor(request);
|
|
1228
|
+
const made = layout(faker, plan, request.count);
|
|
1229
|
+
const batch = Math.max(1, Math.min(MAX_BATCH_ROWS, Math.floor(MAX_PARAMS / Math.max(prepared.length, 1))));
|
|
1230
|
+
let inserted = 0;
|
|
1231
|
+
let skipped = 0;
|
|
1232
|
+
let first;
|
|
1233
|
+
await session.run('BEGIN');
|
|
1234
|
+
try {
|
|
1235
|
+
for (let done = 0; done < made.total; done += batch) {
|
|
1236
|
+
const size = Math.min(batch, made.total - done);
|
|
1237
|
+
if (prepared.length === 0) {
|
|
1238
|
+
for (let i = 0; i < size; i++) {
|
|
1239
|
+
const result = await session.run(`INSERT INTO ${qualified(table)} DEFAULT VALUES`);
|
|
1240
|
+
inserted += result.rowCount ?? 0;
|
|
1241
|
+
}
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
const rows = makeRows(faker, plan, made, done, size);
|
|
1245
|
+
skipped += size - rows.length;
|
|
1246
|
+
if (rows.length === 0)
|
|
1247
|
+
continue;
|
|
1248
|
+
first ??= rows[0];
|
|
1249
|
+
const result = await session.run(insertSql(table, prepared, rows.length, request.skipConflicts), rows.flat());
|
|
1250
|
+
inserted += result.rowCount ?? 0;
|
|
1251
|
+
}
|
|
1252
|
+
await session.run('COMMIT');
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
await session.run('ROLLBACK').catch(() => undefined);
|
|
1256
|
+
throw error;
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
inserted,
|
|
1260
|
+
requested: made.total,
|
|
1261
|
+
skipped,
|
|
1262
|
+
durationMs: performance.now() - started,
|
|
1263
|
+
sql: display(table, prepared, first ?? [], request.skipConflicts),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1267
|
+
// Conditions one column may have.
|
|
1268
|
+
const MAX_RULES = 20;
|
|
1269
|
+
// Links one request may name.
|
|
1270
|
+
const MAX_LINKS = 20;
|
|
1271
|
+
function parseLinks(raw) {
|
|
1272
|
+
if (!Array.isArray(raw) || raw.length > MAX_LINKS) {
|
|
1273
|
+
throw new SeedError(`links is a list of up to ${MAX_LINKS} {"name", "schema"?, "table"?, "mode"?, "unique"?}`);
|
|
1274
|
+
}
|
|
1275
|
+
return raw.map((link) => {
|
|
1276
|
+
if (!isRecord(link) || typeof link.name !== 'string' || !link.name.trim()) {
|
|
1277
|
+
throw new SeedError('a link needs a name');
|
|
1278
|
+
}
|
|
1279
|
+
const mode = linkModes.find((known) => known === link.mode);
|
|
1280
|
+
if (link.mode !== undefined && !mode) {
|
|
1281
|
+
throw new SeedError(`the link "${link.name}": mode is ${linkModes.join(' or ')}`);
|
|
1282
|
+
}
|
|
1283
|
+
return {
|
|
1284
|
+
name: link.name,
|
|
1285
|
+
...(typeof link.schema === 'string' && link.schema ? { schema: link.schema } : {}),
|
|
1286
|
+
...(typeof link.table === 'string' && link.table ? { table: link.table } : {}),
|
|
1287
|
+
...(mode ? { mode } : {}),
|
|
1288
|
+
...(link.unique === true ? { unique: true } : {}),
|
|
1289
|
+
};
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/** One plan as sent, without its rules. */
|
|
1293
|
+
function parsePlan(name, raw) {
|
|
1294
|
+
if (!isRecord(raw) || typeof raw.generator !== 'string') {
|
|
1295
|
+
throw new SeedError(`"${name}" needs a generator`);
|
|
1296
|
+
}
|
|
1297
|
+
const options = {};
|
|
1298
|
+
if (raw.options !== undefined) {
|
|
1299
|
+
if (!isRecord(raw.options))
|
|
1300
|
+
throw new SeedError(`"${name}": options is {name: value}`);
|
|
1301
|
+
for (const [key, value] of Object.entries(raw.options)) {
|
|
1302
|
+
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
1303
|
+
options[key] = value;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
const nulls = Number(raw.nulls);
|
|
1308
|
+
let items;
|
|
1309
|
+
if (raw.items !== undefined) {
|
|
1310
|
+
if (!isRecord(raw.items))
|
|
1311
|
+
throw new SeedError(`"${name}": items is {"min", "max"}`);
|
|
1312
|
+
items = itemsOf({
|
|
1313
|
+
generator: raw.generator,
|
|
1314
|
+
items: { min: Number(raw.items.min), max: Number(raw.items.max) },
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
generator: raw.generator,
|
|
1319
|
+
options,
|
|
1320
|
+
...(Number.isFinite(nulls) && nulls > 0 ? { nulls } : {}),
|
|
1321
|
+
...(raw.unique === true ? { unique: true } : {}),
|
|
1322
|
+
...(items ? { items } : {}),
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
function parseCondition(name, raw) {
|
|
1326
|
+
const op = isRecord(raw) ? conditionOps.find((known) => known === raw.op) : undefined;
|
|
1327
|
+
if (!isRecord(raw) || typeof raw.column !== 'string' || !raw.column || !op) {
|
|
1328
|
+
throw new SeedError(`"${name}": a condition is {"column", "op": ${conditionOps.join(' | ')}, "value"?}`);
|
|
1329
|
+
}
|
|
1330
|
+
if (op === 'null' || op === 'notNull')
|
|
1331
|
+
return { column: raw.column, op };
|
|
1332
|
+
return {
|
|
1333
|
+
column: raw.column,
|
|
1334
|
+
op,
|
|
1335
|
+
value: raw.value === undefined || raw.value === null ? '' : String(raw.value),
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
/** A request body as sent by the Seed page, checked; throws a SeedError saying what is wrong. */
|
|
1339
|
+
export function parseSeedRequest(body) {
|
|
1340
|
+
if (!isRecord(body) || !isRecord(body.columns)) {
|
|
1341
|
+
throw new SeedError('send {"count", "columns": {name: {"generator", "options"?, "nulls"?, "unique"?}}}');
|
|
1342
|
+
}
|
|
1343
|
+
const count = Number(body.count);
|
|
1344
|
+
checkCount(count);
|
|
1345
|
+
const columns = {};
|
|
1346
|
+
for (const [name, raw] of Object.entries(body.columns)) {
|
|
1347
|
+
const chosen = parsePlan(name, raw);
|
|
1348
|
+
let rules;
|
|
1349
|
+
if (isRecord(raw) && raw.rules !== undefined) {
|
|
1350
|
+
if (!Array.isArray(raw.rules))
|
|
1351
|
+
throw new SeedError(`"${name}": rules is a list of {"when", "then"}`);
|
|
1352
|
+
if (raw.rules.length > MAX_RULES)
|
|
1353
|
+
throw new SeedError(`"${name}" has more than ${MAX_RULES} conditions`);
|
|
1354
|
+
rules = raw.rules.map((rule) => {
|
|
1355
|
+
if (!isRecord(rule))
|
|
1356
|
+
throw new SeedError(`"${name}": a rule is {"when", "then"}`);
|
|
1357
|
+
return { when: parseCondition(name, rule.when), then: parsePlan(name, rule.then) };
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
columns[name] = rules?.length ? { ...chosen, rules } : chosen;
|
|
1361
|
+
}
|
|
1362
|
+
const seed = Number(body.seed);
|
|
1363
|
+
const locale = seedLocales.find((known) => known === body.locale);
|
|
1364
|
+
return {
|
|
1365
|
+
count,
|
|
1366
|
+
columns,
|
|
1367
|
+
...(body.seed !== undefined && body.seed !== '' && Number.isInteger(seed) ? { seed } : {}),
|
|
1368
|
+
...(locale ? { locale } : {}),
|
|
1369
|
+
...(body.skipConflicts === true ? { skipConflicts: true } : {}),
|
|
1370
|
+
...(body.skipDuplicates === true ? { skipDuplicates: true } : {}),
|
|
1371
|
+
...(body.links !== undefined ? { links: parseLinks(body.links) } : {}),
|
|
1372
|
+
...(isRecord(body.perRow)
|
|
1373
|
+
? { perRow: perRowOf({ perRow: { min: Number(body.perRow.min), max: Number(body.perRow.max) } }) }
|
|
1374
|
+
: {}),
|
|
1375
|
+
};
|
|
1376
|
+
}
|