@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
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @zmdb/migrations
|
|
2
|
+
|
|
3
|
+
Schema snapshots, deterministic migration plans, ledger runners, embedded execution, catalog introspection, and declaration emission for zmdb.
|
|
4
|
+
|
|
5
|
+
Part of **[zmdb](https://github.com/ambasta/zmdb)**.
|
|
6
|
+
|
|
7
|
+
Applications that install the product package can import the curated lifecycle surface from `zmdb/migrations`. Direct package consumers and advanced tooling use the entry points below.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
yarn add @zmdb/migrations@1.0.0-beta.1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Install the selected database package separately when planning dialect-specific SQL or connecting to a database, for example `@zmdb/sqlite@1.0.0-beta.1`. The application supplies the driver or
|
|
16
|
+
connection.
|
|
17
|
+
|
|
18
|
+
## Library and command workflows
|
|
19
|
+
|
|
20
|
+
The root's `snapshot`, `diff` and `planMigration` APIs operate on schema data. A migration plan receives the selected dialect and its `emitUp` and `emitDown` functions. `@zmdb/migrations/runner`
|
|
21
|
+
applies or rolls back migrations through a caller-supplied connection and records them in the ledger. Introspection and declaration emission have their own entries below.
|
|
22
|
+
|
|
23
|
+
For a command workflow, install `@zmdb/cli@1.0.0-beta.1` with `typescript@^7.0.2` and use its `zmdb generate`, `migrate`, `rollback`, `status`, `pull` and `embed` commands. The CLI loads project
|
|
24
|
+
configuration through `@zmdb/compiler/config`; the migrations library does not load a TypeScript project or depend on the CLI or compiler.
|
|
25
|
+
|
|
26
|
+
`@zmdb/migrations/embedded` exports `runEmbedded(connection, migrations)` for bundle-resident SQLite migrations. It consumes precomputed migration records and the connection's `exec`, `run` and `rows`
|
|
27
|
+
methods, without importing filesystem APIs, a database driver or compiler tooling. Use the CLI's `embed` command to produce those records during the build.
|
|
28
|
+
|
|
29
|
+
## Entry points
|
|
30
|
+
|
|
31
|
+
`@zmdb/migrations`, `@zmdb/migrations/declarations`, `@zmdb/migrations/embedded`, `@zmdb/migrations/files`, `@zmdb/migrations/introspect`, `@zmdb/migrations/introspect/runtime`,
|
|
32
|
+
`@zmdb/migrations/runner`, `@zmdb/migrations/testing`
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
GNU General Public License v3.0 or later (GPL-3.0-or-later) — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SchemaSnapshot, type SqlDialect } from '@zmdb/sql';
|
|
2
|
+
import { type CatalogWarning } from '../introspect/common.js';
|
|
3
|
+
export interface EmitOptions {
|
|
4
|
+
/** Required because a schema snapshot is deliberately dialect-neutral. */
|
|
5
|
+
readonly dialect: SqlDialect;
|
|
6
|
+
}
|
|
7
|
+
export interface EmittedDeclarationFile {
|
|
8
|
+
readonly path: string;
|
|
9
|
+
readonly source: string;
|
|
10
|
+
}
|
|
11
|
+
export interface EmitDeclarationsResult {
|
|
12
|
+
readonly files: readonly EmittedDeclarationFile[];
|
|
13
|
+
readonly warnings: readonly CatalogWarning[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Turn a deterministic schema snapshot into formatter-clean tagged declarations.
|
|
17
|
+
*
|
|
18
|
+
* Catalog-only evidence is read structurally so an ordinary `SchemaSnapshot` remains a
|
|
19
|
+
* valid input. That keeps the forward migration snapshot dialect-neutral while allowing
|
|
20
|
+
* the richer introspection snapshot to contribute defaults, keys, indexes and warnings.
|
|
21
|
+
*/
|
|
22
|
+
export declare function emitDeclarations(snapshot: SchemaSnapshot, options: EmitOptions): Promise<EmitDeclarationsResult>;
|
|
23
|
+
//# sourceMappingURL=emit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"emit.d.ts","sourceRoot":"","sources":["../../src/declarations/emit.ts"],"names":[],"mappings":"AACA,OAAO,EAAuB,KAAK,cAAc,EAAE,KAAK,UAAU,EAAsB,MAAM,WAAW,CAAC;AAG1G,OAAO,EAAgB,KAAK,cAAc,EAA0B,MAAM,yBAAyB,CAAC;AAGpG,MAAM,WAAW,WAAW;IAC1B,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;CAC9B;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,KAAK,EAAE,SAAS,sBAAsB,EAAE,CAAC;IAClD,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;CAC9C;AAwGD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,cAAc,EACxB,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,sBAAsB,CAAC,CAqBjC"}
|
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import { singularPascalCase } from '@zmdb/schema/naming';
|
|
2
|
+
import {} from '@zmdb/sql';
|
|
3
|
+
import { sortWarnings } from '../introspect/common.js';
|
|
4
|
+
import { escapeTypeString, renderTaggedProperty, typescriptPropertyName } from './tagged-property.js';
|
|
5
|
+
const FORMAT_OPTIONS = {
|
|
6
|
+
arrowParens: 'avoid',
|
|
7
|
+
bracketSpacing: true,
|
|
8
|
+
endOfLine: 'lf',
|
|
9
|
+
insertFinalNewline: true,
|
|
10
|
+
objectWrap: 'preserve',
|
|
11
|
+
printWidth: 120,
|
|
12
|
+
quoteProps: 'as-needed',
|
|
13
|
+
semi: true,
|
|
14
|
+
singleQuote: true,
|
|
15
|
+
sortImports: true,
|
|
16
|
+
tabWidth: 2,
|
|
17
|
+
trailingComma: 'all',
|
|
18
|
+
useTabs: false,
|
|
19
|
+
};
|
|
20
|
+
const RESERVED_INTERFACE_NAMES = new Set([
|
|
21
|
+
'Date',
|
|
22
|
+
'Ext',
|
|
23
|
+
'HasDefault',
|
|
24
|
+
'Length',
|
|
25
|
+
'ManyToOne',
|
|
26
|
+
'Max',
|
|
27
|
+
'MaxLength',
|
|
28
|
+
'Min',
|
|
29
|
+
'MinLength',
|
|
30
|
+
'OneToOne',
|
|
31
|
+
'Pattern',
|
|
32
|
+
'PrimaryKey',
|
|
33
|
+
'References',
|
|
34
|
+
'Rule',
|
|
35
|
+
'Sensitive',
|
|
36
|
+
'Serial',
|
|
37
|
+
'Sql',
|
|
38
|
+
'Table',
|
|
39
|
+
'Unique',
|
|
40
|
+
]);
|
|
41
|
+
class WarningCollector {
|
|
42
|
+
#warnings = new Map();
|
|
43
|
+
constructor(initial) {
|
|
44
|
+
for (const warning of initial)
|
|
45
|
+
this.add(warning);
|
|
46
|
+
}
|
|
47
|
+
add(warning) {
|
|
48
|
+
const key = `${warning.table}\0${warning.column ?? ''}\0${warning.reason}`;
|
|
49
|
+
this.#warnings.set(key, warning);
|
|
50
|
+
}
|
|
51
|
+
values() {
|
|
52
|
+
return sortWarnings([...this.#warnings.values()]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Turn a deterministic schema snapshot into formatter-clean tagged declarations.
|
|
57
|
+
*
|
|
58
|
+
* Catalog-only evidence is read structurally so an ordinary `SchemaSnapshot` remains a
|
|
59
|
+
* valid input. That keeps the forward migration snapshot dialect-neutral while allowing
|
|
60
|
+
* the richer introspection snapshot to contribute defaults, keys, indexes and warnings.
|
|
61
|
+
*/
|
|
62
|
+
export async function emitDeclarations(snapshot, options) {
|
|
63
|
+
const warnings = new WarningCollector(snapshotWarnings(snapshot));
|
|
64
|
+
const tables = tablePlans(snapshot.tables, warnings);
|
|
65
|
+
const byName = new Map(tables.map(table => [table.table.name, table]));
|
|
66
|
+
const emissions = tables.map(table => analyzeTable(table, byName, warnings));
|
|
67
|
+
const finalWarnings = warnings.values();
|
|
68
|
+
const rawFiles = [
|
|
69
|
+
...emissions.map(emission => ({
|
|
70
|
+
path: `${emission.table.fileStem}.ts`,
|
|
71
|
+
source: renderTableFile(emission, finalWarnings, options.dialect, snapshot.version),
|
|
72
|
+
})),
|
|
73
|
+
{
|
|
74
|
+
path: 'index.ts',
|
|
75
|
+
source: renderIndexFile(tables, finalWarnings, options.dialect, snapshot.version),
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
const files = await Promise.all(rawFiles.map(async (file) => ({ path: file.path, source: await formatSource(file.path, file.source) })));
|
|
79
|
+
return { files, warnings: finalWarnings };
|
|
80
|
+
}
|
|
81
|
+
function tablePlans(tables, warnings) {
|
|
82
|
+
const sorted = tables.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
83
|
+
const duplicate = sorted.find((table, index) => sorted.findIndex(candidate => candidate.name === table.name) !== index);
|
|
84
|
+
if (duplicate !== undefined) {
|
|
85
|
+
throw new TypeError(`cannot emit two declarations for physical table "${duplicate.name}"`);
|
|
86
|
+
}
|
|
87
|
+
const usedNames = new Set();
|
|
88
|
+
const usedFiles = new Set(['index']);
|
|
89
|
+
return sorted.map(table => {
|
|
90
|
+
const interfaceName = uniqueInterfaceName(table.name, usedNames, warnings);
|
|
91
|
+
const fileStem = uniqueFileStem(table.name, usedFiles, warnings);
|
|
92
|
+
return {
|
|
93
|
+
table,
|
|
94
|
+
interfaceName,
|
|
95
|
+
fileStem,
|
|
96
|
+
primaryKey: tablePrimaryKey(table),
|
|
97
|
+
foreignKeys: tableForeignKeys(table),
|
|
98
|
+
indexes: tableIndexes(table),
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function uniqueInterfaceName(table, used, warnings) {
|
|
103
|
+
const proposed = singularPascalCase(table);
|
|
104
|
+
let base = proposed;
|
|
105
|
+
if (!/^[$A-Z_a-z][$\w]*$/.test(proposed) || RESERVED_INTERFACE_NAMES.has(proposed)) {
|
|
106
|
+
base = `Table${encodedName(table)}`;
|
|
107
|
+
warnings.add({
|
|
108
|
+
table,
|
|
109
|
+
reason: `Physical table name "${table}" did not produce a safe TypeScript interface name; emitted ${base}`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let candidate = base;
|
|
113
|
+
let suffix = 2;
|
|
114
|
+
while (used.has(candidate)) {
|
|
115
|
+
candidate = `${base}${String(suffix)}`;
|
|
116
|
+
suffix += 1;
|
|
117
|
+
}
|
|
118
|
+
if (candidate !== base) {
|
|
119
|
+
warnings.add({
|
|
120
|
+
table,
|
|
121
|
+
reason: `Interface name ${base} collides with another table; emitted ${candidate}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
used.add(candidate);
|
|
125
|
+
return candidate;
|
|
126
|
+
}
|
|
127
|
+
function uniqueFileStem(table, used, warnings) {
|
|
128
|
+
const safe = /^[A-Za-z0-9._-]+$/.test(table) && table !== '.' && table !== '..';
|
|
129
|
+
const base = safe ? table : `table-${encodedName(table).toLowerCase()}`;
|
|
130
|
+
let candidate = base;
|
|
131
|
+
let suffix = 2;
|
|
132
|
+
while (used.has(candidate.toLowerCase())) {
|
|
133
|
+
candidate = `${base}-${String(suffix)}`;
|
|
134
|
+
suffix += 1;
|
|
135
|
+
}
|
|
136
|
+
if (!safe || candidate !== base) {
|
|
137
|
+
warnings.add({
|
|
138
|
+
table,
|
|
139
|
+
reason: `Physical table name "${table}" is not a unique safe file name; emitted ${candidate}.ts`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
used.add(candidate.toLowerCase());
|
|
143
|
+
return candidate;
|
|
144
|
+
}
|
|
145
|
+
function encodedName(value) {
|
|
146
|
+
const encoded = Array.from(value, character => character.codePointAt(0)?.toString(16) ?? '0').join('_');
|
|
147
|
+
return encoded || 'Empty';
|
|
148
|
+
}
|
|
149
|
+
function analyzeTable(table, tables, warnings) {
|
|
150
|
+
recordIndexWarnings(table, warnings);
|
|
151
|
+
recordReferentialActionWarnings(table, warnings);
|
|
152
|
+
const references = referenceTargets(table, warnings);
|
|
153
|
+
const tags = new Set(['Table']);
|
|
154
|
+
const properties = table.table.columns
|
|
155
|
+
.toSorted((left, right) => left.name.localeCompare(right.name))
|
|
156
|
+
.map((column) => {
|
|
157
|
+
if (column.type === 'json') {
|
|
158
|
+
warnings.add({
|
|
159
|
+
table: table.table.name,
|
|
160
|
+
column: column.name,
|
|
161
|
+
reason: `Catalog type ${catalogType(column)} has no payload shape; emitted object`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const defaultExpression = columnDefault(column);
|
|
165
|
+
const length = column.length;
|
|
166
|
+
const enumValues = columnEnumValues(column);
|
|
167
|
+
const reference = references.get(column.name);
|
|
168
|
+
const rendered = renderTaggedProperty(column.name, {
|
|
169
|
+
sql: column.type,
|
|
170
|
+
nullable: column.nullable,
|
|
171
|
+
primaryKey: column.primaryKey || table.primaryKey.includes(column.name),
|
|
172
|
+
unique: columnIsUnique(table, column.name),
|
|
173
|
+
hasDefault: defaultExpression !== undefined,
|
|
174
|
+
...(length === undefined ? {} : { length }),
|
|
175
|
+
...(enumValues === undefined ? {} : { enumValues }),
|
|
176
|
+
...(reference === undefined ? {} : { references: reference }),
|
|
177
|
+
});
|
|
178
|
+
if ('reason' in rendered) {
|
|
179
|
+
warnings.add({
|
|
180
|
+
table: table.table.name,
|
|
181
|
+
column: column.name,
|
|
182
|
+
reason: unrepresentableReason(column, rendered.reason),
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
column,
|
|
186
|
+
...(defaultExpression === undefined ? {} : { defaultExpression }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
for (const tag of rendered.tags)
|
|
190
|
+
tags.add(tag);
|
|
191
|
+
return {
|
|
192
|
+
column,
|
|
193
|
+
source: rendered.source,
|
|
194
|
+
...(defaultExpression === undefined ? {} : { defaultExpression }),
|
|
195
|
+
};
|
|
196
|
+
});
|
|
197
|
+
const relations = relationPlans(table, tables, warnings);
|
|
198
|
+
for (const relation of relations)
|
|
199
|
+
tags.add(relation.tag);
|
|
200
|
+
return { table, properties, relations, tags };
|
|
201
|
+
}
|
|
202
|
+
function referenceTargets(table, warnings) {
|
|
203
|
+
const byColumn = new Map();
|
|
204
|
+
for (const foreignKey of table.foreignKeys) {
|
|
205
|
+
if (foreignKey.columns.length !== foreignKey.targetColumns.length) {
|
|
206
|
+
warnings.add({
|
|
207
|
+
table: table.table.name,
|
|
208
|
+
reason: `Foreign key "${foreignKey.name}" has ${String(foreignKey.columns.length)} local columns and ` +
|
|
209
|
+
`${String(foreignKey.targetColumns.length)} target columns; References tags were omitted`,
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
foreignKey.columns.forEach((column, position) => {
|
|
214
|
+
const matches = byColumn.get(column);
|
|
215
|
+
const evidence = { foreignKey, position };
|
|
216
|
+
if (matches)
|
|
217
|
+
matches.push(evidence);
|
|
218
|
+
else
|
|
219
|
+
byColumn.set(column, [evidence]);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const result = new Map();
|
|
223
|
+
for (const [column, matches] of byColumn) {
|
|
224
|
+
if (matches.length !== 1) {
|
|
225
|
+
warnings.add({
|
|
226
|
+
table: table.table.name,
|
|
227
|
+
column,
|
|
228
|
+
reason: `Column participates in ${String(matches.length)} foreign keys; its References tag is ambiguous and was omitted`,
|
|
229
|
+
});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const match = matches[0];
|
|
233
|
+
if (match === undefined)
|
|
234
|
+
continue;
|
|
235
|
+
const targetColumn = match.foreignKey.targetColumns[match.position];
|
|
236
|
+
if (targetColumn === undefined)
|
|
237
|
+
continue;
|
|
238
|
+
result.set(column, `${match.foreignKey.targetTable}.${targetColumn}`);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
function relationPlans(table, tables, warnings) {
|
|
243
|
+
const byTarget = new Map();
|
|
244
|
+
for (const foreignKey of table.foreignKeys) {
|
|
245
|
+
const values = byTarget.get(foreignKey.targetTable);
|
|
246
|
+
if (values)
|
|
247
|
+
values.push(foreignKey);
|
|
248
|
+
else
|
|
249
|
+
byTarget.set(foreignKey.targetTable, [foreignKey]);
|
|
250
|
+
}
|
|
251
|
+
const candidates = [];
|
|
252
|
+
for (const [targetTable, foreignKeys] of [...byTarget].toSorted(([left], [right]) => left.localeCompare(right))) {
|
|
253
|
+
if (foreignKeys.length > 1) {
|
|
254
|
+
warnings.add({
|
|
255
|
+
table: table.table.name,
|
|
256
|
+
reason: `Foreign keys ${foreignKeys.map(key => `"${key.name}"`).join(', ')} all target "${targetTable}"; ` +
|
|
257
|
+
'the relation property is ambiguous and was omitted',
|
|
258
|
+
});
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const foreignKey = foreignKeys[0];
|
|
262
|
+
if (foreignKey === undefined)
|
|
263
|
+
continue;
|
|
264
|
+
if (foreignKey.columns.length !== 1 || foreignKey.targetColumns.length !== 1) {
|
|
265
|
+
warnings.add({
|
|
266
|
+
table: table.table.name,
|
|
267
|
+
reason: `Composite foreign key "${foreignKey.name}" cannot be represented by one relation property and was omitted`,
|
|
268
|
+
});
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const localColumn = foreignKey.columns[0];
|
|
272
|
+
if (localColumn === undefined)
|
|
273
|
+
continue;
|
|
274
|
+
const target = tables.get(targetTable);
|
|
275
|
+
if (target === undefined) {
|
|
276
|
+
warnings.add({
|
|
277
|
+
table: table.table.name,
|
|
278
|
+
column: localColumn,
|
|
279
|
+
reason: `Foreign key "${foreignKey.name}" targets table "${targetTable}", which is absent from the snapshot`,
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const name = lowerFirst(target.interfaceName);
|
|
284
|
+
if (table.table.columns.some(column => column.name === name)) {
|
|
285
|
+
warnings.add({
|
|
286
|
+
table: table.table.name,
|
|
287
|
+
column: localColumn,
|
|
288
|
+
reason: `Relation name "${name}" collides with a physical column and was omitted`,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const tag = foreignKeyIsUnique(table, foreignKey) ? 'OneToOne' : 'ManyToOne';
|
|
293
|
+
candidates.push({
|
|
294
|
+
name,
|
|
295
|
+
target,
|
|
296
|
+
tag,
|
|
297
|
+
source: ` ${typescriptPropertyName(name)}?: ${target.interfaceName} & ` +
|
|
298
|
+
`${tag}<'${escapeTypeString(targetTable)}', '${escapeTypeString(localColumn)}'>;`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const nameCounts = new Map();
|
|
302
|
+
for (const candidate of candidates)
|
|
303
|
+
nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
|
304
|
+
return candidates.filter(candidate => {
|
|
305
|
+
if (nameCounts.get(candidate.name) === 1)
|
|
306
|
+
return true;
|
|
307
|
+
warnings.add({
|
|
308
|
+
table: table.table.name,
|
|
309
|
+
reason: `More than one foreign key produced relation name "${candidate.name}"; those relations were omitted`,
|
|
310
|
+
});
|
|
311
|
+
return false;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function foreignKeyIsUnique(table, foreignKey) {
|
|
315
|
+
if (sameColumns(table.primaryKey, foreignKey.columns))
|
|
316
|
+
return true;
|
|
317
|
+
return table.indexes.some(index => index.unique &&
|
|
318
|
+
index.where === undefined &&
|
|
319
|
+
index.columns.every(column => typeof column === 'string') &&
|
|
320
|
+
sameColumns(index.columns.filter(column => typeof column === 'string'), foreignKey.columns));
|
|
321
|
+
}
|
|
322
|
+
function sameColumns(left, right) {
|
|
323
|
+
return left.length === right.length && left.every(column => right.includes(column));
|
|
324
|
+
}
|
|
325
|
+
function columnIsUnique(table, column) {
|
|
326
|
+
return table.indexes.some(index => index.unique && index.where === undefined && index.columns.length === 1 && index.columns[0] === column);
|
|
327
|
+
}
|
|
328
|
+
function recordIndexWarnings(table, warnings) {
|
|
329
|
+
for (const index of table.indexes) {
|
|
330
|
+
if (index.unique &&
|
|
331
|
+
index.where === undefined &&
|
|
332
|
+
index.columns.length === 1 &&
|
|
333
|
+
typeof index.columns[0] === 'string') {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
warnings.add({
|
|
337
|
+
table: table.table.name,
|
|
338
|
+
reason: `Index "${index.name}" is not representable by a single-column Unique tag and was not emitted`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function recordReferentialActionWarnings(table, warnings) {
|
|
343
|
+
for (const foreignKey of table.foreignKeys) {
|
|
344
|
+
const onDelete = foreignKey.onDelete ?? 'no action';
|
|
345
|
+
const onUpdate = foreignKey.onUpdate ?? 'no action';
|
|
346
|
+
if (onDelete === 'no action' && onUpdate === 'no action')
|
|
347
|
+
continue;
|
|
348
|
+
warnings.add({
|
|
349
|
+
table: table.table.name,
|
|
350
|
+
reason: `Foreign key "${foreignKey.name}" has ON DELETE ${onDelete.toUpperCase()} and ` +
|
|
351
|
+
`ON UPDATE ${onUpdate.toUpperCase()}, which current declaration tags cannot retain`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function renderTableFile(emission, warnings, dialect, version) {
|
|
356
|
+
const table = emission.table;
|
|
357
|
+
const tableWarnings = warnings.filter(warning => warning.table === table.table.name);
|
|
358
|
+
const tableLevel = tableWarnings.filter(warning => warning.column === undefined);
|
|
359
|
+
const knownColumns = new Set(emission.properties.map(property => property.column.name));
|
|
360
|
+
const orphaned = tableWarnings.filter(warning => warning.column !== undefined && !knownColumns.has(warning.column));
|
|
361
|
+
const importedTargets = emission.relations
|
|
362
|
+
.map(relation => relation.target)
|
|
363
|
+
.filter(target => target.table.name !== table.table.name)
|
|
364
|
+
.filter((target, index, values) => values.findIndex(candidate => candidate.interfaceName === target.interfaceName) === index)
|
|
365
|
+
.toSorted((left, right) => left.interfaceName.localeCompare(right.interfaceName));
|
|
366
|
+
const lines = header(dialect, version);
|
|
367
|
+
lines.push(`import type { ${[...emission.tags].toSorted().join(', ')} } from '@zmdb/schema/tags';`);
|
|
368
|
+
for (const target of importedTargets) {
|
|
369
|
+
lines.push(`import type { ${target.interfaceName} } from './${escapeTypeString(target.fileStem)}.js';`);
|
|
370
|
+
}
|
|
371
|
+
lines.push('');
|
|
372
|
+
for (const warning of tableLevel)
|
|
373
|
+
lines.push(todoComment(warning));
|
|
374
|
+
lines.push(`export interface ${table.interfaceName} extends Table<'${escapeTypeString(table.table.name)}'> {`);
|
|
375
|
+
for (const property of emission.properties) {
|
|
376
|
+
for (const warning of tableWarnings.filter(candidate => candidate.column === property.column.name)) {
|
|
377
|
+
lines.push(todoComment(warning, ' '));
|
|
378
|
+
}
|
|
379
|
+
if (property.defaultExpression !== undefined) {
|
|
380
|
+
lines.push(` // Database default expression: ${JSON.stringify(property.defaultExpression)}`);
|
|
381
|
+
}
|
|
382
|
+
if (property.source !== undefined)
|
|
383
|
+
lines.push(property.source);
|
|
384
|
+
}
|
|
385
|
+
for (const relation of emission.relations.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
|
386
|
+
lines.push(relation.source);
|
|
387
|
+
}
|
|
388
|
+
for (const warning of orphaned)
|
|
389
|
+
lines.push(todoComment(warning, ' '));
|
|
390
|
+
lines.push('}', '');
|
|
391
|
+
return lines.join('\n');
|
|
392
|
+
}
|
|
393
|
+
function renderIndexFile(tables, warnings, dialect, version) {
|
|
394
|
+
const lines = header(dialect, version);
|
|
395
|
+
const globalWarnings = warnings.filter(warning => warning.table === '*' || !tables.some(table => table.table.name === warning.table));
|
|
396
|
+
for (const warning of globalWarnings)
|
|
397
|
+
lines.push(todoComment(warning));
|
|
398
|
+
if (globalWarnings.length > 0)
|
|
399
|
+
lines.push('');
|
|
400
|
+
for (const table of tables) {
|
|
401
|
+
lines.push(`export type { ${table.interfaceName} } from './${escapeTypeString(table.fileStem)}.js';`);
|
|
402
|
+
}
|
|
403
|
+
lines.push('');
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
|
406
|
+
function header(dialect, version) {
|
|
407
|
+
return [
|
|
408
|
+
`// Generated by zmdb introspection from a ${dialect.name} database. Do not edit; regenerate instead.`,
|
|
409
|
+
`// Snapshot version ${String(version)}. Hand edits are overwritten wholesale.`,
|
|
410
|
+
'',
|
|
411
|
+
];
|
|
412
|
+
}
|
|
413
|
+
function todoComment(warning, indent = '') {
|
|
414
|
+
const subject = warning.column === undefined ? '' : `${warning.column}: `;
|
|
415
|
+
return `${indent}// TODO: ${oneLine(`${subject}${warning.reason}`)}`;
|
|
416
|
+
}
|
|
417
|
+
function oneLine(value) {
|
|
418
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
419
|
+
}
|
|
420
|
+
function unrepresentableReason(column, detail) {
|
|
421
|
+
const type = catalogType(column);
|
|
422
|
+
if (column.type === 'jsonEnum') {
|
|
423
|
+
return `Catalog type ${type} cannot be represented without its enum members; column omitted (${detail})`;
|
|
424
|
+
}
|
|
425
|
+
return `Catalog type ${type} cannot be represented without data loss; column omitted (${detail})`;
|
|
426
|
+
}
|
|
427
|
+
function catalogType(column) {
|
|
428
|
+
const type = optionalString(column, 'catalogType') ?? snapshotTypeName(column.type);
|
|
429
|
+
return type || '(untyped column)';
|
|
430
|
+
}
|
|
431
|
+
function snapshotTypeName(type) {
|
|
432
|
+
if (typeof type === 'string')
|
|
433
|
+
return type;
|
|
434
|
+
const args = type.args ?? [];
|
|
435
|
+
return args.length === 0 ? type.name : `${type.name}(${args.map(String).join(',')})`;
|
|
436
|
+
}
|
|
437
|
+
function columnDefault(column) {
|
|
438
|
+
return optionalString(column, 'default');
|
|
439
|
+
}
|
|
440
|
+
function columnEnumValues(column) {
|
|
441
|
+
const value = Reflect.get(column, 'enumValues');
|
|
442
|
+
return stringArray(value) ? value : undefined;
|
|
443
|
+
}
|
|
444
|
+
function tablePrimaryKey(table) {
|
|
445
|
+
const value = Reflect.get(table, 'primaryKey');
|
|
446
|
+
if (stringArray(value))
|
|
447
|
+
return value;
|
|
448
|
+
return table.columns.filter(column => column.primaryKey).map(column => column.name);
|
|
449
|
+
}
|
|
450
|
+
function tableForeignKeys(table) {
|
|
451
|
+
const value = Reflect.get(table, 'foreignKeys');
|
|
452
|
+
if (!Array.isArray(value))
|
|
453
|
+
return [];
|
|
454
|
+
const result = [];
|
|
455
|
+
for (const candidate of value) {
|
|
456
|
+
if (!record(candidate))
|
|
457
|
+
continue;
|
|
458
|
+
const name = optionalString(candidate, 'name');
|
|
459
|
+
const columns = Reflect.get(candidate, 'columns');
|
|
460
|
+
const targetTable = optionalString(candidate, 'targetTable');
|
|
461
|
+
const targetColumns = Reflect.get(candidate, 'targetColumns');
|
|
462
|
+
if (name === undefined || !stringArray(columns) || targetTable === undefined || !stringArray(targetColumns)) {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const onDelete = referentialAction(Reflect.get(candidate, 'onDelete'));
|
|
466
|
+
const onUpdate = referentialAction(Reflect.get(candidate, 'onUpdate'));
|
|
467
|
+
result.push({
|
|
468
|
+
name,
|
|
469
|
+
columns,
|
|
470
|
+
targetTable,
|
|
471
|
+
targetColumns,
|
|
472
|
+
...(onDelete === undefined ? {} : { onDelete }),
|
|
473
|
+
...(onUpdate === undefined ? {} : { onUpdate }),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
return result.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
477
|
+
}
|
|
478
|
+
function tableIndexes(table) {
|
|
479
|
+
const value = Reflect.get(table, 'indexes');
|
|
480
|
+
if (!Array.isArray(value))
|
|
481
|
+
return [];
|
|
482
|
+
const result = [];
|
|
483
|
+
for (const candidate of value) {
|
|
484
|
+
if (!record(candidate))
|
|
485
|
+
continue;
|
|
486
|
+
const name = optionalString(candidate, 'name');
|
|
487
|
+
const columns = indexColumns(Reflect.get(candidate, 'columns'));
|
|
488
|
+
const unique = optionalBoolean(candidate, 'unique');
|
|
489
|
+
if (name === undefined || columns === undefined || unique === undefined)
|
|
490
|
+
continue;
|
|
491
|
+
const where = optionalString(candidate, 'where');
|
|
492
|
+
result.push({
|
|
493
|
+
name,
|
|
494
|
+
columns,
|
|
495
|
+
unique,
|
|
496
|
+
...(where === undefined ? {} : { where }),
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return result.toSorted((left, right) => left.name.localeCompare(right.name));
|
|
500
|
+
}
|
|
501
|
+
function indexColumns(value) {
|
|
502
|
+
if (!Array.isArray(value))
|
|
503
|
+
return undefined;
|
|
504
|
+
const result = [];
|
|
505
|
+
for (const candidate of value) {
|
|
506
|
+
if (typeof candidate === 'string') {
|
|
507
|
+
result.push(candidate);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
if (!record(candidate))
|
|
511
|
+
return undefined;
|
|
512
|
+
const column = optionalString(candidate, 'column');
|
|
513
|
+
const expression = optionalString(candidate, 'expr');
|
|
514
|
+
if (column !== undefined) {
|
|
515
|
+
if (expression !== undefined)
|
|
516
|
+
return undefined;
|
|
517
|
+
result.push({ column });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (expression === undefined)
|
|
521
|
+
return undefined;
|
|
522
|
+
result.push({ expr: expression });
|
|
523
|
+
}
|
|
524
|
+
return result;
|
|
525
|
+
}
|
|
526
|
+
function snapshotWarnings(snapshot) {
|
|
527
|
+
const value = Reflect.get(snapshot, 'warnings');
|
|
528
|
+
if (!Array.isArray(value))
|
|
529
|
+
return [];
|
|
530
|
+
const result = [];
|
|
531
|
+
for (const candidate of value) {
|
|
532
|
+
if (!record(candidate))
|
|
533
|
+
continue;
|
|
534
|
+
const table = optionalString(candidate, 'table');
|
|
535
|
+
const column = optionalString(candidate, 'column');
|
|
536
|
+
const reason = optionalString(candidate, 'reason');
|
|
537
|
+
if (table === undefined || reason === undefined)
|
|
538
|
+
continue;
|
|
539
|
+
result.push({ table, ...(column === undefined ? {} : { column }), reason });
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
function referentialAction(value) {
|
|
544
|
+
switch (value) {
|
|
545
|
+
case 'no action':
|
|
546
|
+
case 'restrict':
|
|
547
|
+
case 'cascade':
|
|
548
|
+
case 'set null':
|
|
549
|
+
case 'set default':
|
|
550
|
+
return value;
|
|
551
|
+
default:
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
function optionalString(value, key) {
|
|
556
|
+
const candidate = Reflect.get(value, key);
|
|
557
|
+
return typeof candidate === 'string' ? candidate : undefined;
|
|
558
|
+
}
|
|
559
|
+
function optionalBoolean(value, key) {
|
|
560
|
+
const candidate = Reflect.get(value, key);
|
|
561
|
+
return typeof candidate === 'boolean' ? candidate : undefined;
|
|
562
|
+
}
|
|
563
|
+
function stringArray(value) {
|
|
564
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
565
|
+
}
|
|
566
|
+
function record(value) {
|
|
567
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
568
|
+
}
|
|
569
|
+
function lowerFirst(value) {
|
|
570
|
+
const first = value[0];
|
|
571
|
+
return first === undefined ? value : `${first.toLowerCase()}${value.slice(1)}`;
|
|
572
|
+
}
|
|
573
|
+
async function formatSource(path, source) {
|
|
574
|
+
const { format } = await import('oxfmt');
|
|
575
|
+
const result = await format(path, source, FORMAT_OPTIONS);
|
|
576
|
+
if (result.errors.length > 0) {
|
|
577
|
+
throw new TypeError(`oxfmt could not format generated ${path}: ${JSON.stringify(result.errors)}`);
|
|
578
|
+
}
|
|
579
|
+
return result.code;
|
|
580
|
+
}
|
|
581
|
+
//# sourceMappingURL=emit.js.map
|