@zmdb/migrations 1.0.0-beta.1
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 +674 -0
- package/README.md +36 -0
- package/dist/declarations/emit.d.ts +23 -0
- package/dist/declarations/emit.d.ts.map +1 -0
- package/dist/declarations/emit.js +581 -0
- package/dist/declarations/emit.js.map +1 -0
- package/dist/declarations/index.d.ts +3 -0
- package/dist/declarations/index.d.ts.map +1 -0
- package/dist/declarations/index.js +3 -0
- package/dist/declarations/index.js.map +1 -0
- package/dist/declarations/tagged-property.d.ts +33 -0
- package/dist/declarations/tagged-property.d.ts.map +1 -0
- package/dist/declarations/tagged-property.js +148 -0
- package/dist/declarations/tagged-property.js.map +1 -0
- package/dist/embedded.d.ts +25 -0
- package/dist/embedded.d.ts.map +1 -0
- package/dist/embedded.js +133 -0
- package/dist/embedded.js.map +1 -0
- package/dist/file-io.d.ts +15 -0
- package/dist/file-io.d.ts.map +1 -0
- package/dist/file-io.js +92 -0
- package/dist/file-io.js.map +1 -0
- package/dist/files.d.ts +11 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +10 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +70 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +350 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect/common.d.ts +26 -0
- package/dist/introspect/common.d.ts.map +1 -0
- package/dist/introspect/common.js +155 -0
- package/dist/introspect/common.js.map +1 -0
- package/dist/introspect/drift.d.ts +45 -0
- package/dist/introspect/drift.d.ts.map +1 -0
- package/dist/introspect/drift.js +84 -0
- package/dist/introspect/drift.js.map +1 -0
- package/dist/introspect/index.d.ts +8 -0
- package/dist/introspect/index.d.ts.map +1 -0
- package/dist/introspect/index.js +10 -0
- package/dist/introspect/index.js.map +1 -0
- package/dist/operations/check.d.ts +16 -0
- package/dist/operations/check.d.ts.map +1 -0
- package/dist/operations/check.js +236 -0
- package/dist/operations/check.js.map +1 -0
- package/dist/operations/embed.d.ts +23 -0
- package/dist/operations/embed.d.ts.map +1 -0
- package/dist/operations/embed.js +52 -0
- package/dist/operations/embed.js.map +1 -0
- package/dist/operations/export.d.ts +9 -0
- package/dist/operations/export.d.ts.map +1 -0
- package/dist/operations/export.js +15 -0
- package/dist/operations/export.js.map +1 -0
- package/dist/operations/generate.d.ts +17 -0
- package/dist/operations/generate.d.ts.map +1 -0
- package/dist/operations/generate.js +115 -0
- package/dist/operations/generate.js.map +1 -0
- package/dist/operations/migrate.d.ts +23 -0
- package/dist/operations/migrate.d.ts.map +1 -0
- package/dist/operations/migrate.js +67 -0
- package/dist/operations/migrate.js.map +1 -0
- package/dist/operations/pull.d.ts +40 -0
- package/dist/operations/pull.d.ts.map +1 -0
- package/dist/operations/pull.js +100 -0
- package/dist/operations/pull.js.map +1 -0
- package/dist/operations/push.d.ts +20 -0
- package/dist/operations/push.d.ts.map +1 -0
- package/dist/operations/push.js +82 -0
- package/dist/operations/push.js.map +1 -0
- package/dist/operations/upgrade.d.ts +10 -0
- package/dist/operations/upgrade.d.ts.map +1 -0
- package/dist/operations/upgrade.js +38 -0
- package/dist/operations/upgrade.js.map +1 -0
- package/dist/project.d.ts +46 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +42 -0
- package/dist/project.js.map +1 -0
- package/dist/runner.d.ts +59 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +163 -0
- package/dist/runner.js.map +1 -0
- package/dist/testing/official-dialects.fixture.d.ts +16 -0
- package/dist/testing/official-dialects.fixture.d.ts.map +1 -0
- package/dist/testing/official-dialects.fixture.js +21 -0
- package/dist/testing/official-dialects.fixture.js.map +1 -0
- package/dist/testing.d.ts +8 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +27 -0
- package/dist/testing.js.map +1 -0
- package/package.json +81 -0
- package/src/declarations/emit.ts +705 -0
- package/src/declarations/index.ts +13 -0
- package/src/declarations/tagged-property.ts +163 -0
- package/src/embedded.ts +192 -0
- package/src/file-io.ts +114 -0
- package/src/files.ts +34 -0
- package/src/index.ts +502 -0
- package/src/introspect/__fixtures__/mysql-8.4.11.json +371 -0
- package/src/introspect/common.ts +203 -0
- package/src/introspect/drift.ts +157 -0
- package/src/introspect/index.ts +33 -0
- package/src/operations/check.ts +309 -0
- package/src/operations/embed.ts +87 -0
- package/src/operations/export.ts +21 -0
- package/src/operations/generate.ts +144 -0
- package/src/operations/migrate.ts +110 -0
- package/src/operations/pull.ts +160 -0
- package/src/operations/push.ts +98 -0
- package/src/operations/upgrade.ts +56 -0
- package/src/project.ts +93 -0
- package/src/runner.ts +260 -0
- package/src/testing/official-dialects.fixture.ts +24 -0
- package/src/testing.ts +32 -0
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { singularPascalCase } from '@zmdb/schema/naming';
|
|
2
|
+
import { type ColumnSnapshot, type SchemaSnapshot, type SqlDialect, type TableSnapshot } from '@zmdb/sql';
|
|
3
|
+
import type { FormatConfig } from 'oxfmt';
|
|
4
|
+
|
|
5
|
+
import { sortWarnings, type CatalogWarning, type ReferentialAction } from '../introspect/common.js';
|
|
6
|
+
import { escapeTypeString, renderTaggedProperty, typescriptPropertyName } from './tagged-property.js';
|
|
7
|
+
|
|
8
|
+
export interface EmitOptions {
|
|
9
|
+
/** Required because a schema snapshot is deliberately dialect-neutral. */
|
|
10
|
+
readonly dialect: SqlDialect;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface EmittedDeclarationFile {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly source: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface EmitDeclarationsResult {
|
|
19
|
+
readonly files: readonly EmittedDeclarationFile[];
|
|
20
|
+
readonly warnings: readonly CatalogWarning[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ForeignKeyEvidence {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly columns: readonly string[];
|
|
26
|
+
readonly targetTable: string;
|
|
27
|
+
readonly targetColumns: readonly string[];
|
|
28
|
+
readonly onDelete?: ReferentialAction;
|
|
29
|
+
readonly onUpdate?: ReferentialAction;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type IndexColumnEvidence = string | { readonly column: string } | { readonly expr: string };
|
|
33
|
+
|
|
34
|
+
interface IndexEvidence {
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly columns: readonly IndexColumnEvidence[];
|
|
37
|
+
readonly unique: boolean;
|
|
38
|
+
readonly where?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TablePlan {
|
|
42
|
+
readonly table: TableSnapshot;
|
|
43
|
+
readonly interfaceName: string;
|
|
44
|
+
readonly fileStem: string;
|
|
45
|
+
readonly primaryKey: readonly string[];
|
|
46
|
+
readonly foreignKeys: readonly ForeignKeyEvidence[];
|
|
47
|
+
readonly indexes: readonly IndexEvidence[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PropertyPlan {
|
|
51
|
+
readonly column: ColumnSnapshot;
|
|
52
|
+
readonly source?: string;
|
|
53
|
+
readonly defaultExpression?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RelationPlan {
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly source: string;
|
|
59
|
+
readonly target: TablePlan;
|
|
60
|
+
readonly tag: 'ManyToOne' | 'OneToOne';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface TableEmissionPlan {
|
|
64
|
+
readonly table: TablePlan;
|
|
65
|
+
readonly properties: readonly PropertyPlan[];
|
|
66
|
+
readonly relations: readonly RelationPlan[];
|
|
67
|
+
readonly tags: ReadonlySet<string>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const FORMAT_OPTIONS: FormatConfig = {
|
|
71
|
+
arrowParens: 'avoid',
|
|
72
|
+
bracketSpacing: true,
|
|
73
|
+
endOfLine: 'lf',
|
|
74
|
+
insertFinalNewline: true,
|
|
75
|
+
objectWrap: 'preserve',
|
|
76
|
+
printWidth: 120,
|
|
77
|
+
quoteProps: 'as-needed',
|
|
78
|
+
semi: true,
|
|
79
|
+
singleQuote: true,
|
|
80
|
+
sortImports: true,
|
|
81
|
+
tabWidth: 2,
|
|
82
|
+
trailingComma: 'all',
|
|
83
|
+
useTabs: false,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const RESERVED_INTERFACE_NAMES = new Set([
|
|
87
|
+
'Date',
|
|
88
|
+
'Ext',
|
|
89
|
+
'HasDefault',
|
|
90
|
+
'Length',
|
|
91
|
+
'ManyToOne',
|
|
92
|
+
'Max',
|
|
93
|
+
'MaxLength',
|
|
94
|
+
'Min',
|
|
95
|
+
'MinLength',
|
|
96
|
+
'OneToOne',
|
|
97
|
+
'Pattern',
|
|
98
|
+
'PrimaryKey',
|
|
99
|
+
'References',
|
|
100
|
+
'Rule',
|
|
101
|
+
'Sensitive',
|
|
102
|
+
'Serial',
|
|
103
|
+
'Sql',
|
|
104
|
+
'Table',
|
|
105
|
+
'Unique',
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
class WarningCollector {
|
|
109
|
+
readonly #warnings = new Map<string, CatalogWarning>();
|
|
110
|
+
|
|
111
|
+
constructor(initial: readonly CatalogWarning[]) {
|
|
112
|
+
for (const warning of initial) this.add(warning);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
add(warning: CatalogWarning): void {
|
|
116
|
+
const key = `${warning.table}\0${warning.column ?? ''}\0${warning.reason}`;
|
|
117
|
+
this.#warnings.set(key, warning);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
values(): readonly CatalogWarning[] {
|
|
121
|
+
return sortWarnings([...this.#warnings.values()]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Turn a deterministic schema snapshot into formatter-clean tagged declarations.
|
|
127
|
+
*
|
|
128
|
+
* Catalog-only evidence is read structurally so an ordinary `SchemaSnapshot` remains a
|
|
129
|
+
* valid input. That keeps the forward migration snapshot dialect-neutral while allowing
|
|
130
|
+
* the richer introspection snapshot to contribute defaults, keys, indexes and warnings.
|
|
131
|
+
*/
|
|
132
|
+
export async function emitDeclarations(
|
|
133
|
+
snapshot: SchemaSnapshot,
|
|
134
|
+
options: EmitOptions,
|
|
135
|
+
): Promise<EmitDeclarationsResult> {
|
|
136
|
+
const warnings = new WarningCollector(snapshotWarnings(snapshot));
|
|
137
|
+
const tables = tablePlans(snapshot.tables, warnings);
|
|
138
|
+
const byName = new Map(tables.map(table => [table.table.name, table]));
|
|
139
|
+
const emissions = tables.map(table => analyzeTable(table, byName, warnings));
|
|
140
|
+
const finalWarnings = warnings.values();
|
|
141
|
+
|
|
142
|
+
const rawFiles = [
|
|
143
|
+
...emissions.map(emission => ({
|
|
144
|
+
path: `${emission.table.fileStem}.ts`,
|
|
145
|
+
source: renderTableFile(emission, finalWarnings, options.dialect, snapshot.version),
|
|
146
|
+
})),
|
|
147
|
+
{
|
|
148
|
+
path: 'index.ts',
|
|
149
|
+
source: renderIndexFile(tables, finalWarnings, options.dialect, snapshot.version),
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
const files = await Promise.all(
|
|
153
|
+
rawFiles.map(async file => ({ path: file.path, source: await formatSource(file.path, file.source) })),
|
|
154
|
+
);
|
|
155
|
+
return { files, warnings: finalWarnings };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function tablePlans(tables: readonly TableSnapshot[], warnings: WarningCollector): readonly TablePlan[] {
|
|
159
|
+
const sorted = tables.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
160
|
+
const duplicate = sorted.find(
|
|
161
|
+
(table, index) => sorted.findIndex(candidate => candidate.name === table.name) !== index,
|
|
162
|
+
);
|
|
163
|
+
if (duplicate !== undefined) {
|
|
164
|
+
throw new TypeError(`cannot emit two declarations for physical table "${duplicate.name}"`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const usedNames = new Set<string>();
|
|
168
|
+
const usedFiles = new Set<string>(['index']);
|
|
169
|
+
return sorted.map(table => {
|
|
170
|
+
const interfaceName = uniqueInterfaceName(table.name, usedNames, warnings);
|
|
171
|
+
const fileStem = uniqueFileStem(table.name, usedFiles, warnings);
|
|
172
|
+
return {
|
|
173
|
+
table,
|
|
174
|
+
interfaceName,
|
|
175
|
+
fileStem,
|
|
176
|
+
primaryKey: tablePrimaryKey(table),
|
|
177
|
+
foreignKeys: tableForeignKeys(table),
|
|
178
|
+
indexes: tableIndexes(table),
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function uniqueInterfaceName(table: string, used: Set<string>, warnings: WarningCollector): string {
|
|
184
|
+
const proposed = singularPascalCase(table);
|
|
185
|
+
let base = proposed;
|
|
186
|
+
if (!/^[$A-Z_a-z][$\w]*$/.test(proposed) || RESERVED_INTERFACE_NAMES.has(proposed)) {
|
|
187
|
+
base = `Table${encodedName(table)}`;
|
|
188
|
+
warnings.add({
|
|
189
|
+
table,
|
|
190
|
+
reason: `Physical table name "${table}" did not produce a safe TypeScript interface name; emitted ${base}`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let candidate = base;
|
|
195
|
+
let suffix = 2;
|
|
196
|
+
while (used.has(candidate)) {
|
|
197
|
+
candidate = `${base}${String(suffix)}`;
|
|
198
|
+
suffix += 1;
|
|
199
|
+
}
|
|
200
|
+
if (candidate !== base) {
|
|
201
|
+
warnings.add({
|
|
202
|
+
table,
|
|
203
|
+
reason: `Interface name ${base} collides with another table; emitted ${candidate}`,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
used.add(candidate);
|
|
207
|
+
return candidate;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function uniqueFileStem(table: string, used: Set<string>, warnings: WarningCollector): string {
|
|
211
|
+
const safe = /^[A-Za-z0-9._-]+$/.test(table) && table !== '.' && table !== '..';
|
|
212
|
+
const base = safe ? table : `table-${encodedName(table).toLowerCase()}`;
|
|
213
|
+
let candidate = base;
|
|
214
|
+
let suffix = 2;
|
|
215
|
+
while (used.has(candidate.toLowerCase())) {
|
|
216
|
+
candidate = `${base}-${String(suffix)}`;
|
|
217
|
+
suffix += 1;
|
|
218
|
+
}
|
|
219
|
+
if (!safe || candidate !== base) {
|
|
220
|
+
warnings.add({
|
|
221
|
+
table,
|
|
222
|
+
reason: `Physical table name "${table}" is not a unique safe file name; emitted ${candidate}.ts`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
used.add(candidate.toLowerCase());
|
|
226
|
+
return candidate;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function encodedName(value: string): string {
|
|
230
|
+
const encoded = Array.from(value, character => character.codePointAt(0)?.toString(16) ?? '0').join('_');
|
|
231
|
+
return encoded || 'Empty';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function analyzeTable(
|
|
235
|
+
table: TablePlan,
|
|
236
|
+
tables: ReadonlyMap<string, TablePlan>,
|
|
237
|
+
warnings: WarningCollector,
|
|
238
|
+
): TableEmissionPlan {
|
|
239
|
+
recordIndexWarnings(table, warnings);
|
|
240
|
+
recordReferentialActionWarnings(table, warnings);
|
|
241
|
+
|
|
242
|
+
const references = referenceTargets(table, warnings);
|
|
243
|
+
const tags = new Set<string>(['Table']);
|
|
244
|
+
const properties = table.table.columns
|
|
245
|
+
.toSorted((left, right) => left.name.localeCompare(right.name))
|
|
246
|
+
.map((column): PropertyPlan => {
|
|
247
|
+
if (column.type === 'json') {
|
|
248
|
+
warnings.add({
|
|
249
|
+
table: table.table.name,
|
|
250
|
+
column: column.name,
|
|
251
|
+
reason: `Catalog type ${catalogType(column)} has no payload shape; emitted object`,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const defaultExpression = columnDefault(column);
|
|
256
|
+
const length = column.length;
|
|
257
|
+
const enumValues = columnEnumValues(column);
|
|
258
|
+
const reference = references.get(column.name);
|
|
259
|
+
const rendered = renderTaggedProperty(column.name, {
|
|
260
|
+
sql: column.type,
|
|
261
|
+
nullable: column.nullable,
|
|
262
|
+
primaryKey: column.primaryKey || table.primaryKey.includes(column.name),
|
|
263
|
+
unique: columnIsUnique(table, column.name),
|
|
264
|
+
hasDefault: defaultExpression !== undefined,
|
|
265
|
+
...(length === undefined ? {} : { length }),
|
|
266
|
+
...(enumValues === undefined ? {} : { enumValues }),
|
|
267
|
+
...(reference === undefined ? {} : { references: reference }),
|
|
268
|
+
});
|
|
269
|
+
if ('reason' in rendered) {
|
|
270
|
+
warnings.add({
|
|
271
|
+
table: table.table.name,
|
|
272
|
+
column: column.name,
|
|
273
|
+
reason: unrepresentableReason(column, rendered.reason),
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
column,
|
|
277
|
+
...(defaultExpression === undefined ? {} : { defaultExpression }),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
for (const tag of rendered.tags) tags.add(tag);
|
|
281
|
+
return {
|
|
282
|
+
column,
|
|
283
|
+
source: rendered.source,
|
|
284
|
+
...(defaultExpression === undefined ? {} : { defaultExpression }),
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const relations = relationPlans(table, tables, warnings);
|
|
289
|
+
for (const relation of relations) tags.add(relation.tag);
|
|
290
|
+
return { table, properties, relations, tags };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function referenceTargets(table: TablePlan, warnings: WarningCollector): ReadonlyMap<string, string> {
|
|
294
|
+
const byColumn = new Map<string, { readonly foreignKey: ForeignKeyEvidence; readonly position: number }[]>();
|
|
295
|
+
for (const foreignKey of table.foreignKeys) {
|
|
296
|
+
if (foreignKey.columns.length !== foreignKey.targetColumns.length) {
|
|
297
|
+
warnings.add({
|
|
298
|
+
table: table.table.name,
|
|
299
|
+
reason:
|
|
300
|
+
`Foreign key "${foreignKey.name}" has ${String(foreignKey.columns.length)} local columns and ` +
|
|
301
|
+
`${String(foreignKey.targetColumns.length)} target columns; References tags were omitted`,
|
|
302
|
+
});
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
foreignKey.columns.forEach((column, position) => {
|
|
306
|
+
const matches = byColumn.get(column);
|
|
307
|
+
const evidence = { foreignKey, position };
|
|
308
|
+
if (matches) matches.push(evidence);
|
|
309
|
+
else byColumn.set(column, [evidence]);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const result = new Map<string, string>();
|
|
314
|
+
for (const [column, matches] of byColumn) {
|
|
315
|
+
if (matches.length !== 1) {
|
|
316
|
+
warnings.add({
|
|
317
|
+
table: table.table.name,
|
|
318
|
+
column,
|
|
319
|
+
reason: `Column participates in ${String(matches.length)} foreign keys; its References tag is ambiguous and was omitted`,
|
|
320
|
+
});
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const match = matches[0];
|
|
324
|
+
if (match === undefined) continue;
|
|
325
|
+
const targetColumn = match.foreignKey.targetColumns[match.position];
|
|
326
|
+
if (targetColumn === undefined) continue;
|
|
327
|
+
result.set(column, `${match.foreignKey.targetTable}.${targetColumn}`);
|
|
328
|
+
}
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function relationPlans(
|
|
333
|
+
table: TablePlan,
|
|
334
|
+
tables: ReadonlyMap<string, TablePlan>,
|
|
335
|
+
warnings: WarningCollector,
|
|
336
|
+
): readonly RelationPlan[] {
|
|
337
|
+
const byTarget = new Map<string, ForeignKeyEvidence[]>();
|
|
338
|
+
for (const foreignKey of table.foreignKeys) {
|
|
339
|
+
const values = byTarget.get(foreignKey.targetTable);
|
|
340
|
+
if (values) values.push(foreignKey);
|
|
341
|
+
else byTarget.set(foreignKey.targetTable, [foreignKey]);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const candidates: RelationPlan[] = [];
|
|
345
|
+
for (const [targetTable, foreignKeys] of [...byTarget].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
346
|
+
if (foreignKeys.length > 1) {
|
|
347
|
+
warnings.add({
|
|
348
|
+
table: table.table.name,
|
|
349
|
+
reason:
|
|
350
|
+
`Foreign keys ${foreignKeys.map(key => `"${key.name}"`).join(', ')} all target "${targetTable}"; ` +
|
|
351
|
+
'the relation property is ambiguous and was omitted',
|
|
352
|
+
});
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const foreignKey = foreignKeys[0];
|
|
356
|
+
if (foreignKey === undefined) continue;
|
|
357
|
+
if (foreignKey.columns.length !== 1 || foreignKey.targetColumns.length !== 1) {
|
|
358
|
+
warnings.add({
|
|
359
|
+
table: table.table.name,
|
|
360
|
+
reason: `Composite foreign key "${foreignKey.name}" cannot be represented by one relation property and was omitted`,
|
|
361
|
+
});
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const localColumn = foreignKey.columns[0];
|
|
365
|
+
if (localColumn === undefined) continue;
|
|
366
|
+
const target = tables.get(targetTable);
|
|
367
|
+
if (target === undefined) {
|
|
368
|
+
warnings.add({
|
|
369
|
+
table: table.table.name,
|
|
370
|
+
column: localColumn,
|
|
371
|
+
reason: `Foreign key "${foreignKey.name}" targets table "${targetTable}", which is absent from the snapshot`,
|
|
372
|
+
});
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const name = lowerFirst(target.interfaceName);
|
|
376
|
+
if (table.table.columns.some(column => column.name === name)) {
|
|
377
|
+
warnings.add({
|
|
378
|
+
table: table.table.name,
|
|
379
|
+
column: localColumn,
|
|
380
|
+
reason: `Relation name "${name}" collides with a physical column and was omitted`,
|
|
381
|
+
});
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
const tag = foreignKeyIsUnique(table, foreignKey) ? 'OneToOne' : 'ManyToOne';
|
|
385
|
+
candidates.push({
|
|
386
|
+
name,
|
|
387
|
+
target,
|
|
388
|
+
tag,
|
|
389
|
+
source:
|
|
390
|
+
` ${typescriptPropertyName(name)}?: ${target.interfaceName} & ` +
|
|
391
|
+
`${tag}<'${escapeTypeString(targetTable)}', '${escapeTypeString(localColumn)}'>;`,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const nameCounts = new Map<string, number>();
|
|
396
|
+
for (const candidate of candidates) nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
|
397
|
+
return candidates.filter(candidate => {
|
|
398
|
+
if (nameCounts.get(candidate.name) === 1) return true;
|
|
399
|
+
warnings.add({
|
|
400
|
+
table: table.table.name,
|
|
401
|
+
reason: `More than one foreign key produced relation name "${candidate.name}"; those relations were omitted`,
|
|
402
|
+
});
|
|
403
|
+
return false;
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function foreignKeyIsUnique(table: TablePlan, foreignKey: ForeignKeyEvidence): boolean {
|
|
408
|
+
if (sameColumns(table.primaryKey, foreignKey.columns)) return true;
|
|
409
|
+
return table.indexes.some(
|
|
410
|
+
index =>
|
|
411
|
+
index.unique &&
|
|
412
|
+
index.where === undefined &&
|
|
413
|
+
index.columns.every(column => typeof column === 'string') &&
|
|
414
|
+
sameColumns(
|
|
415
|
+
index.columns.filter(column => typeof column === 'string'),
|
|
416
|
+
foreignKey.columns,
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function sameColumns(left: readonly string[], right: readonly string[]): boolean {
|
|
422
|
+
return left.length === right.length && left.every(column => right.includes(column));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function columnIsUnique(table: TablePlan, column: string): boolean {
|
|
426
|
+
return table.indexes.some(
|
|
427
|
+
index => index.unique && index.where === undefined && index.columns.length === 1 && index.columns[0] === column,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function recordIndexWarnings(table: TablePlan, warnings: WarningCollector): void {
|
|
432
|
+
for (const index of table.indexes) {
|
|
433
|
+
if (
|
|
434
|
+
index.unique &&
|
|
435
|
+
index.where === undefined &&
|
|
436
|
+
index.columns.length === 1 &&
|
|
437
|
+
typeof index.columns[0] === 'string'
|
|
438
|
+
) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
warnings.add({
|
|
442
|
+
table: table.table.name,
|
|
443
|
+
reason: `Index "${index.name}" is not representable by a single-column Unique tag and was not emitted`,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function recordReferentialActionWarnings(table: TablePlan, warnings: WarningCollector): void {
|
|
449
|
+
for (const foreignKey of table.foreignKeys) {
|
|
450
|
+
const onDelete = foreignKey.onDelete ?? 'no action';
|
|
451
|
+
const onUpdate = foreignKey.onUpdate ?? 'no action';
|
|
452
|
+
if (onDelete === 'no action' && onUpdate === 'no action') continue;
|
|
453
|
+
warnings.add({
|
|
454
|
+
table: table.table.name,
|
|
455
|
+
reason:
|
|
456
|
+
`Foreign key "${foreignKey.name}" has ON DELETE ${onDelete.toUpperCase()} and ` +
|
|
457
|
+
`ON UPDATE ${onUpdate.toUpperCase()}, which current declaration tags cannot retain`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderTableFile(
|
|
463
|
+
emission: TableEmissionPlan,
|
|
464
|
+
warnings: readonly CatalogWarning[],
|
|
465
|
+
dialect: SqlDialect,
|
|
466
|
+
version: SchemaSnapshot['version'],
|
|
467
|
+
): string {
|
|
468
|
+
const table = emission.table;
|
|
469
|
+
const tableWarnings = warnings.filter(warning => warning.table === table.table.name);
|
|
470
|
+
const tableLevel = tableWarnings.filter(warning => warning.column === undefined);
|
|
471
|
+
const knownColumns = new Set(emission.properties.map(property => property.column.name));
|
|
472
|
+
const orphaned = tableWarnings.filter(warning => warning.column !== undefined && !knownColumns.has(warning.column));
|
|
473
|
+
const importedTargets = emission.relations
|
|
474
|
+
.map(relation => relation.target)
|
|
475
|
+
.filter(target => target.table.name !== table.table.name)
|
|
476
|
+
.filter(
|
|
477
|
+
(target, index, values) =>
|
|
478
|
+
values.findIndex(candidate => candidate.interfaceName === target.interfaceName) === index,
|
|
479
|
+
)
|
|
480
|
+
.toSorted((left, right) => left.interfaceName.localeCompare(right.interfaceName));
|
|
481
|
+
|
|
482
|
+
const lines = header(dialect, version);
|
|
483
|
+
lines.push(`import type { ${[...emission.tags].toSorted().join(', ')} } from '@zmdb/schema/tags';`);
|
|
484
|
+
for (const target of importedTargets) {
|
|
485
|
+
lines.push(`import type { ${target.interfaceName} } from './${escapeTypeString(target.fileStem)}.js';`);
|
|
486
|
+
}
|
|
487
|
+
lines.push('');
|
|
488
|
+
for (const warning of tableLevel) lines.push(todoComment(warning));
|
|
489
|
+
lines.push(`export interface ${table.interfaceName} extends Table<'${escapeTypeString(table.table.name)}'> {`);
|
|
490
|
+
for (const property of emission.properties) {
|
|
491
|
+
for (const warning of tableWarnings.filter(candidate => candidate.column === property.column.name)) {
|
|
492
|
+
lines.push(todoComment(warning, ' '));
|
|
493
|
+
}
|
|
494
|
+
if (property.defaultExpression !== undefined) {
|
|
495
|
+
lines.push(` // Database default expression: ${JSON.stringify(property.defaultExpression)}`);
|
|
496
|
+
}
|
|
497
|
+
if (property.source !== undefined) lines.push(property.source);
|
|
498
|
+
}
|
|
499
|
+
for (const relation of emission.relations.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
|
500
|
+
lines.push(relation.source);
|
|
501
|
+
}
|
|
502
|
+
for (const warning of orphaned) lines.push(todoComment(warning, ' '));
|
|
503
|
+
lines.push('}', '');
|
|
504
|
+
return lines.join('\n');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function renderIndexFile(
|
|
508
|
+
tables: readonly TablePlan[],
|
|
509
|
+
warnings: readonly CatalogWarning[],
|
|
510
|
+
dialect: SqlDialect,
|
|
511
|
+
version: SchemaSnapshot['version'],
|
|
512
|
+
): string {
|
|
513
|
+
const lines = header(dialect, version);
|
|
514
|
+
const globalWarnings = warnings.filter(
|
|
515
|
+
warning => warning.table === '*' || !tables.some(table => table.table.name === warning.table),
|
|
516
|
+
);
|
|
517
|
+
for (const warning of globalWarnings) lines.push(todoComment(warning));
|
|
518
|
+
if (globalWarnings.length > 0) lines.push('');
|
|
519
|
+
for (const table of tables) {
|
|
520
|
+
lines.push(`export type { ${table.interfaceName} } from './${escapeTypeString(table.fileStem)}.js';`);
|
|
521
|
+
}
|
|
522
|
+
lines.push('');
|
|
523
|
+
return lines.join('\n');
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function header(dialect: SqlDialect, version: SchemaSnapshot['version']): string[] {
|
|
527
|
+
return [
|
|
528
|
+
`// Generated by zmdb introspection from a ${dialect.name} database. Do not edit; regenerate instead.`,
|
|
529
|
+
`// Snapshot version ${String(version)}. Hand edits are overwritten wholesale.`,
|
|
530
|
+
'',
|
|
531
|
+
];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function todoComment(warning: CatalogWarning, indent = ''): string {
|
|
535
|
+
const subject = warning.column === undefined ? '' : `${warning.column}: `;
|
|
536
|
+
return `${indent}// TODO: ${oneLine(`${subject}${warning.reason}`)}`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function oneLine(value: string): string {
|
|
540
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function unrepresentableReason(column: ColumnSnapshot, detail: string): string {
|
|
544
|
+
const type = catalogType(column);
|
|
545
|
+
if (column.type === 'jsonEnum') {
|
|
546
|
+
return `Catalog type ${type} cannot be represented without its enum members; column omitted (${detail})`;
|
|
547
|
+
}
|
|
548
|
+
return `Catalog type ${type} cannot be represented without data loss; column omitted (${detail})`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function catalogType(column: ColumnSnapshot): string {
|
|
552
|
+
const type = optionalString(column, 'catalogType') ?? snapshotTypeName(column.type);
|
|
553
|
+
return type || '(untyped column)';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function snapshotTypeName(type: ColumnSnapshot['type']): string {
|
|
557
|
+
if (typeof type === 'string') return type;
|
|
558
|
+
const args = type.args ?? [];
|
|
559
|
+
return args.length === 0 ? type.name : `${type.name}(${args.map(String).join(',')})`;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function columnDefault(column: ColumnSnapshot): string | undefined {
|
|
563
|
+
return optionalString(column, 'default');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function columnEnumValues(column: ColumnSnapshot): readonly string[] | undefined {
|
|
567
|
+
const value = Reflect.get(column, 'enumValues');
|
|
568
|
+
return stringArray(value) ? value : undefined;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function tablePrimaryKey(table: TableSnapshot): readonly string[] {
|
|
572
|
+
const value = Reflect.get(table, 'primaryKey');
|
|
573
|
+
if (stringArray(value)) return value;
|
|
574
|
+
return table.columns.filter(column => column.primaryKey).map(column => column.name);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function tableForeignKeys(table: TableSnapshot): readonly ForeignKeyEvidence[] {
|
|
578
|
+
const value = Reflect.get(table, 'foreignKeys');
|
|
579
|
+
if (!Array.isArray(value)) return [];
|
|
580
|
+
const result: ForeignKeyEvidence[] = [];
|
|
581
|
+
for (const candidate of value) {
|
|
582
|
+
if (!record(candidate)) continue;
|
|
583
|
+
const name = optionalString(candidate, 'name');
|
|
584
|
+
const columns = Reflect.get(candidate, 'columns');
|
|
585
|
+
const targetTable = optionalString(candidate, 'targetTable');
|
|
586
|
+
const targetColumns = Reflect.get(candidate, 'targetColumns');
|
|
587
|
+
if (name === undefined || !stringArray(columns) || targetTable === undefined || !stringArray(targetColumns)) {
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const onDelete = referentialAction(Reflect.get(candidate, 'onDelete'));
|
|
591
|
+
const onUpdate = referentialAction(Reflect.get(candidate, 'onUpdate'));
|
|
592
|
+
result.push({
|
|
593
|
+
name,
|
|
594
|
+
columns,
|
|
595
|
+
targetTable,
|
|
596
|
+
targetColumns,
|
|
597
|
+
...(onDelete === undefined ? {} : { onDelete }),
|
|
598
|
+
...(onUpdate === undefined ? {} : { onUpdate }),
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return result.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function tableIndexes(table: TableSnapshot): readonly IndexEvidence[] {
|
|
605
|
+
const value = Reflect.get(table, 'indexes');
|
|
606
|
+
if (!Array.isArray(value)) return [];
|
|
607
|
+
const result: IndexEvidence[] = [];
|
|
608
|
+
for (const candidate of value) {
|
|
609
|
+
if (!record(candidate)) continue;
|
|
610
|
+
const name = optionalString(candidate, 'name');
|
|
611
|
+
const columns = indexColumns(Reflect.get(candidate, 'columns'));
|
|
612
|
+
const unique = optionalBoolean(candidate, 'unique');
|
|
613
|
+
if (name === undefined || columns === undefined || unique === undefined) continue;
|
|
614
|
+
const where = optionalString(candidate, 'where');
|
|
615
|
+
result.push({
|
|
616
|
+
name,
|
|
617
|
+
columns,
|
|
618
|
+
unique,
|
|
619
|
+
...(where === undefined ? {} : { where }),
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return result.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function indexColumns(value: unknown): readonly IndexColumnEvidence[] | undefined {
|
|
626
|
+
if (!Array.isArray(value)) return undefined;
|
|
627
|
+
const result: IndexColumnEvidence[] = [];
|
|
628
|
+
for (const candidate of value) {
|
|
629
|
+
if (typeof candidate === 'string') {
|
|
630
|
+
result.push(candidate);
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (!record(candidate)) return undefined;
|
|
634
|
+
const column = optionalString(candidate, 'column');
|
|
635
|
+
const expression = optionalString(candidate, 'expr');
|
|
636
|
+
if (column !== undefined) {
|
|
637
|
+
if (expression !== undefined) return undefined;
|
|
638
|
+
result.push({ column });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (expression === undefined) return undefined;
|
|
642
|
+
result.push({ expr: expression });
|
|
643
|
+
}
|
|
644
|
+
return result;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function snapshotWarnings(snapshot: SchemaSnapshot): readonly CatalogWarning[] {
|
|
648
|
+
const value = Reflect.get(snapshot, 'warnings');
|
|
649
|
+
if (!Array.isArray(value)) return [];
|
|
650
|
+
const result: CatalogWarning[] = [];
|
|
651
|
+
for (const candidate of value) {
|
|
652
|
+
if (!record(candidate)) continue;
|
|
653
|
+
const table = optionalString(candidate, 'table');
|
|
654
|
+
const column = optionalString(candidate, 'column');
|
|
655
|
+
const reason = optionalString(candidate, 'reason');
|
|
656
|
+
if (table === undefined || reason === undefined) continue;
|
|
657
|
+
result.push({ table, ...(column === undefined ? {} : { column }), reason });
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function referentialAction(value: unknown): ReferentialAction | undefined {
|
|
663
|
+
switch (value) {
|
|
664
|
+
case 'no action':
|
|
665
|
+
case 'restrict':
|
|
666
|
+
case 'cascade':
|
|
667
|
+
case 'set null':
|
|
668
|
+
case 'set default':
|
|
669
|
+
return value;
|
|
670
|
+
default:
|
|
671
|
+
return undefined;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function optionalString(value: object, key: string): string | undefined {
|
|
676
|
+
const candidate = Reflect.get(value, key);
|
|
677
|
+
return typeof candidate === 'string' ? candidate : undefined;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function optionalBoolean(value: object, key: string): boolean | undefined {
|
|
681
|
+
const candidate = Reflect.get(value, key);
|
|
682
|
+
return typeof candidate === 'boolean' ? candidate : undefined;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function stringArray(value: unknown): value is readonly string[] {
|
|
686
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function record(value: unknown): value is object {
|
|
690
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function lowerFirst(value: string): string {
|
|
694
|
+
const first = value[0];
|
|
695
|
+
return first === undefined ? value : `${first.toLowerCase()}${value.slice(1)}`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function formatSource(path: string, source: string): Promise<string> {
|
|
699
|
+
const { format } = await import('oxfmt');
|
|
700
|
+
const result = await format(path, source, FORMAT_OPTIONS);
|
|
701
|
+
if (result.errors.length > 0) {
|
|
702
|
+
throw new TypeError(`oxfmt could not format generated ${path}: ${JSON.stringify(result.errors)}`);
|
|
703
|
+
}
|
|
704
|
+
return result.code;
|
|
705
|
+
}
|