@zmdb/mysql 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 +6 -0
- package/README.md +111 -0
- package/dist/dialect.d.ts +2 -0
- package/dist/dialect.d.ts.map +1 -0
- package/dist/dialect.js +91 -0
- package/dist/dialect.js.map +1 -0
- package/dist/driver.d.ts +48 -0
- package/dist/driver.d.ts.map +1 -0
- package/dist/driver.js +125 -0
- package/dist/driver.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/introspect.d.ts +11 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.js +306 -0
- package/dist/introspect.js.map +1 -0
- package/dist/migrations.d.ts +25 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +471 -0
- package/dist/migrations.js.map +1 -0
- package/package.json +59 -0
- package/src/__fixtures__/mysql-8.4.11.json +261 -0
- package/src/dialect.ts +99 -0
- package/src/driver.ts +219 -0
- package/src/index.ts +35 -0
- package/src/introspect.ts +430 -0
- package/src/migrations.ts +637 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangeOp,
|
|
3
|
+
ColumnSnapshot,
|
|
4
|
+
ExtensionType,
|
|
5
|
+
ForeignKeySnapshot,
|
|
6
|
+
ReferentialAction,
|
|
7
|
+
SchemaSnapshot,
|
|
8
|
+
} from '@zmdb/migrations';
|
|
9
|
+
import {
|
|
10
|
+
UnsupportedFeatureError,
|
|
11
|
+
type AppliedMigration,
|
|
12
|
+
type DialectTypeMap,
|
|
13
|
+
type MigrationConnection,
|
|
14
|
+
type MigrationDialect,
|
|
15
|
+
type MigrationDriver,
|
|
16
|
+
type MigrationPlan,
|
|
17
|
+
type MigrationTableOptions,
|
|
18
|
+
type SchemaObjectOperation,
|
|
19
|
+
} from '@zmdb/sql';
|
|
20
|
+
import { type IndexColumn, type IndexDef, type RoutineDef, type RoutineSqlType } from '@zmdb/sql/schema-objects';
|
|
21
|
+
|
|
22
|
+
const TYPES = Object.freeze({
|
|
23
|
+
serial: 'INT AUTO_INCREMENT',
|
|
24
|
+
integer: 'INT',
|
|
25
|
+
bigint: 'BIGINT',
|
|
26
|
+
numeric: 'DECIMAL',
|
|
27
|
+
text: 'TEXT',
|
|
28
|
+
varchar: 'VARCHAR',
|
|
29
|
+
boolean: 'TINYINT(1)',
|
|
30
|
+
timestamp: 'DATETIME(3)',
|
|
31
|
+
json: 'JSON',
|
|
32
|
+
jsonEnum: 'TEXT',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const EXTENSION_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
36
|
+
|
|
37
|
+
type CreateTableOperation = Extract<ChangeOp, { readonly kind: 'create_table' }>;
|
|
38
|
+
|
|
39
|
+
export interface MysqlTableDdlHelpers {
|
|
40
|
+
readonly quote: (identifier: string) => string;
|
|
41
|
+
readonly keyColumns: (columns: readonly string[]) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface MysqlTableDdlExtension {
|
|
45
|
+
readonly createPrefix: (operation: CreateTableOperation) => string;
|
|
46
|
+
readonly definitions: (operation: CreateTableOperation, helpers: MysqlTableDdlHelpers) => readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MysqlMigrationOverrides {
|
|
50
|
+
readonly types?: Readonly<Partial<DialectTypeMap>>;
|
|
51
|
+
readonly table?: MysqlTableDdlExtension;
|
|
52
|
+
readonly ledger?: {
|
|
53
|
+
readonly createPrefix: string;
|
|
54
|
+
readonly definitions?: readonly string[];
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function quote(identifier: string): string {
|
|
59
|
+
return `\`${identifier.replaceAll('`', '``')}\``;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extensionType(type: ExtensionType): string {
|
|
63
|
+
if (!EXTENSION_IDENTIFIER.test(type.name)) {
|
|
64
|
+
throw new TypeError(`extension type name ${JSON.stringify(type.name)} is not a SQL identifier`);
|
|
65
|
+
}
|
|
66
|
+
const argumentsSql = (type.args ?? []).map(argument => {
|
|
67
|
+
if (typeof argument === 'number' && Number.isFinite(argument)) return String(argument);
|
|
68
|
+
if (typeof argument === 'string' && EXTENSION_IDENTIFIER.test(argument)) return argument;
|
|
69
|
+
throw new TypeError(
|
|
70
|
+
`extension type ${type.name} argument ${JSON.stringify(argument)} must be a finite number or SQL identifier`,
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
return `${type.name}${argumentsSql.length === 0 ? '' : `(${argumentsSql.join(',')})`}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function unsupported<Name extends string>(name: Name, feature: string, message?: string): UnsupportedFeatureError {
|
|
77
|
+
return new UnsupportedFeatureError(feature, name, message);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ddlType<Name extends string>(name: Name, types: DialectTypeMap, column: ColumnSnapshot): string {
|
|
81
|
+
if (typeof column.type !== 'string') {
|
|
82
|
+
const rendered = extensionType(column.type);
|
|
83
|
+
throw unsupported(
|
|
84
|
+
name,
|
|
85
|
+
`extension type ${rendered}`,
|
|
86
|
+
`${name} does not support extension type ${rendered} on column "${column.name}"`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const mapped = Reflect.get(types, column.type);
|
|
90
|
+
const scalar = typeof mapped === 'string' ? mapped : column.type;
|
|
91
|
+
if (column.type === 'varchar') {
|
|
92
|
+
if (column.length === undefined) return 'TEXT';
|
|
93
|
+
if (!Number.isSafeInteger(column.length) || column.length <= 0) {
|
|
94
|
+
throw new TypeError(`varchar column "${column.name}" length must be a positive safe integer`);
|
|
95
|
+
}
|
|
96
|
+
return `VARCHAR(${String(column.length)})`;
|
|
97
|
+
}
|
|
98
|
+
if (column.type === 'serial' && !column.primaryKey) return `${scalar} UNIQUE`;
|
|
99
|
+
return scalar;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function columnDdl<Name extends string>(
|
|
103
|
+
name: Name,
|
|
104
|
+
types: DialectTypeMap,
|
|
105
|
+
table: string,
|
|
106
|
+
column: ColumnSnapshot,
|
|
107
|
+
key: { readonly inline: boolean; readonly tableLevel: boolean },
|
|
108
|
+
): string {
|
|
109
|
+
const primary = key.inline ? ' PRIMARY KEY' : '';
|
|
110
|
+
const notNull = !key.inline && (!column.nullable || key.tableLevel) ? ' NOT NULL' : '';
|
|
111
|
+
const unique = column.unique === true && column.type !== 'serial' && !key.inline ? ' UNIQUE' : '';
|
|
112
|
+
return `${quote(column.name)} ${ddlType(name, types, column)}${primary}${notNull}${unique}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function keyColumns(columns: readonly string[]): string {
|
|
116
|
+
return columns.map(quote).join(', ');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function actionSql<Name extends string>(name: Name, constraint: string, action: ReferentialAction): string {
|
|
120
|
+
if (action === 'set default') {
|
|
121
|
+
throw unsupported(
|
|
122
|
+
name,
|
|
123
|
+
`SET DEFAULT on foreign key "${constraint}"`,
|
|
124
|
+
`SET DEFAULT on foreign key "${constraint}" is not supported by MySQL; InnoDB accepts the syntax but refuses the constraint`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return action.toUpperCase();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function supportIndexName<Name extends string>(name: Name, foreignKey: ForeignKeySnapshot): string {
|
|
131
|
+
const index = `${foreignKey.name}_idx`;
|
|
132
|
+
if (index.length > 64) {
|
|
133
|
+
throw unsupported(
|
|
134
|
+
name,
|
|
135
|
+
`supporting index "${index}"`,
|
|
136
|
+
`the MySQL supporting index "${index}" is ${String(index.length)} characters long; MySQL's limit is 64`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return index;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function foreignKeyConstraint<Name extends string>(name: Name, foreignKey: ForeignKeySnapshot): string {
|
|
143
|
+
if (foreignKey.columns.length === 0 || foreignKey.columns.length !== foreignKey.targetColumns.length) {
|
|
144
|
+
throw new TypeError(`foreign key "${foreignKey.name}" must map one or more equally sized column lists`);
|
|
145
|
+
}
|
|
146
|
+
return (
|
|
147
|
+
`CONSTRAINT ${quote(foreignKey.name)} FOREIGN KEY (${keyColumns(foreignKey.columns)}) ` +
|
|
148
|
+
`REFERENCES ${quote(foreignKey.targetTable)} (${keyColumns(foreignKey.targetColumns)}) ` +
|
|
149
|
+
`ON DELETE ${actionSql(name, foreignKey.name, foreignKey.onDelete)} ` +
|
|
150
|
+
`ON UPDATE ${actionSql(name, foreignKey.name, foreignKey.onUpdate)}`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function createTable<Name extends string>(
|
|
155
|
+
name: Name,
|
|
156
|
+
types: DialectTypeMap,
|
|
157
|
+
tableExtension: MysqlTableDdlExtension | undefined,
|
|
158
|
+
operation: CreateTableOperation,
|
|
159
|
+
): string {
|
|
160
|
+
if (operation.tableOptions !== undefined && tableExtension === undefined) {
|
|
161
|
+
throw unsupported(
|
|
162
|
+
name,
|
|
163
|
+
`table options on "${operation.table}"`,
|
|
164
|
+
`${name} does not model SingleStore shard keys, sort keys, or rowstore options`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
const available = new Set(operation.columns.map(column => column.name));
|
|
168
|
+
for (const primary of operation.primaryKey) {
|
|
169
|
+
if (!available.has(primary)) throw new TypeError(`primary key on "${operation.table}" names "${primary}"`);
|
|
170
|
+
}
|
|
171
|
+
const inline = operation.primaryKey.length === 1 ? operation.primaryKey[0] : undefined;
|
|
172
|
+
const tableLevel = operation.primaryKey.length > 1 ? new Set(operation.primaryKey) : undefined;
|
|
173
|
+
const definitions = operation.columns.map(column =>
|
|
174
|
+
columnDdl(name, types, operation.table, column, {
|
|
175
|
+
inline: column.name === inline,
|
|
176
|
+
tableLevel: tableLevel?.has(column.name) === true,
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
if (operation.primaryKey.length > 1) {
|
|
180
|
+
definitions.push(`PRIMARY KEY (${keyColumns(operation.primaryKey)})`);
|
|
181
|
+
}
|
|
182
|
+
for (const foreignKey of operation.foreignKeys) {
|
|
183
|
+
for (const column of foreignKey.columns) {
|
|
184
|
+
if (!available.has(column)) {
|
|
185
|
+
throw new TypeError(
|
|
186
|
+
`foreign key "${foreignKey.name}" on "${operation.table}" names unknown column "${column}"`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
definitions.push(`INDEX ${quote(supportIndexName(name, foreignKey))} (${keyColumns(foreignKey.columns)})`);
|
|
191
|
+
definitions.push(foreignKeyConstraint(name, foreignKey));
|
|
192
|
+
}
|
|
193
|
+
if (tableExtension !== undefined) {
|
|
194
|
+
definitions.push(
|
|
195
|
+
...tableExtension.definitions(
|
|
196
|
+
operation,
|
|
197
|
+
Object.freeze({
|
|
198
|
+
quote,
|
|
199
|
+
keyColumns,
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
const prefix = tableExtension?.createPrefix(operation) ?? 'CREATE TABLE';
|
|
205
|
+
return `${prefix} ${quote(operation.table)} (${definitions.join(', ')})`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function addForeignKey<Name extends string>(name: Name, table: string, foreignKey: ForeignKeySnapshot): string {
|
|
209
|
+
const index =
|
|
210
|
+
`CREATE INDEX ${quote(supportIndexName(name, foreignKey))} ON ${quote(table)} ` +
|
|
211
|
+
`(${keyColumns(foreignKey.columns)})`;
|
|
212
|
+
const constraint = `ALTER TABLE ${quote(table)} ADD ${foreignKeyConstraint(name, foreignKey)}`;
|
|
213
|
+
return `${index}; ${constraint}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function dropForeignKey(table: string, constraint: string, supportIndex: boolean): string {
|
|
217
|
+
const drop = `ALTER TABLE ${quote(table)} DROP FOREIGN KEY ${quote(constraint)}`;
|
|
218
|
+
return supportIndex ? `${drop}; DROP INDEX ${quote(`${constraint}_idx`)} ON ${quote(table)}` : drop;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function alteredType<Name extends string>(
|
|
222
|
+
name: Name,
|
|
223
|
+
types: DialectTypeMap,
|
|
224
|
+
operation: Extract<ChangeOp, { readonly kind: 'alter_column_type' }>,
|
|
225
|
+
direction: 'up' | 'down',
|
|
226
|
+
): string {
|
|
227
|
+
const nullable = direction === 'up' ? operation.toNullable : operation.fromNullable;
|
|
228
|
+
if (nullable === undefined) {
|
|
229
|
+
throw unsupported(
|
|
230
|
+
name,
|
|
231
|
+
`altering "${operation.table}"."${operation.column}" without nullability metadata`,
|
|
232
|
+
'MySQL MODIFY COLUMN must restate NULL or NOT NULL; generate the operation from snapshots or provide nullability explicitly',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const type = direction === 'up' ? operation.to : operation.from;
|
|
236
|
+
return `${ddlType(name, types, {
|
|
237
|
+
name: operation.column,
|
|
238
|
+
type,
|
|
239
|
+
nullable,
|
|
240
|
+
primaryKey: false,
|
|
241
|
+
})}${nullable ? ' NULL' : ' NOT NULL'}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function alterPrimaryKey(table: string, from: readonly string[], to: readonly string[]): string {
|
|
245
|
+
const clauses: string[] = [];
|
|
246
|
+
if (from.length > 0) clauses.push('DROP PRIMARY KEY');
|
|
247
|
+
if (to.length > 0) clauses.push(`ADD PRIMARY KEY (${keyColumns(to)})`);
|
|
248
|
+
if (clauses.length === 0) throw new TypeError(`primary key change for "${table}" has no columns`);
|
|
249
|
+
return `ALTER TABLE ${quote(table)} ${clauses.join(', ')}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function emitUp<Name extends string>(
|
|
253
|
+
name: Name,
|
|
254
|
+
types: DialectTypeMap,
|
|
255
|
+
tableExtension: MysqlTableDdlExtension | undefined,
|
|
256
|
+
operation: ChangeOp,
|
|
257
|
+
): string {
|
|
258
|
+
switch (operation.kind) {
|
|
259
|
+
case 'create_extension':
|
|
260
|
+
throw unsupported(name, `extension "${operation.name}"`);
|
|
261
|
+
case 'create_table':
|
|
262
|
+
return createTable(name, types, tableExtension, operation);
|
|
263
|
+
case 'drop_table':
|
|
264
|
+
return `DROP TABLE ${quote(operation.table)}`;
|
|
265
|
+
case 'add_column':
|
|
266
|
+
return (
|
|
267
|
+
`ALTER TABLE ${quote(operation.table)} ADD COLUMN ` +
|
|
268
|
+
columnDdl(name, types, operation.table, operation.column, {
|
|
269
|
+
inline: operation.column.primaryKey,
|
|
270
|
+
tableLevel: false,
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
case 'drop_column':
|
|
274
|
+
return `ALTER TABLE ${quote(operation.table)} DROP COLUMN ${quote(operation.column)}`;
|
|
275
|
+
case 'alter_column_type':
|
|
276
|
+
return (
|
|
277
|
+
`ALTER TABLE ${quote(operation.table)} MODIFY COLUMN ${quote(operation.column)} ` +
|
|
278
|
+
alteredType(name, types, operation, 'up')
|
|
279
|
+
);
|
|
280
|
+
case 'alter_primary_key':
|
|
281
|
+
return alterPrimaryKey(operation.table, operation.from, operation.to);
|
|
282
|
+
case 'add_foreign_key':
|
|
283
|
+
return addForeignKey(name, operation.table, operation.fk);
|
|
284
|
+
case 'drop_foreign_key':
|
|
285
|
+
return dropForeignKey(operation.table, operation.name, false);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function emitDown<Name extends string>(name: Name, types: DialectTypeMap, operation: ChangeOp): string {
|
|
290
|
+
switch (operation.kind) {
|
|
291
|
+
case 'create_extension':
|
|
292
|
+
throw unsupported(name, `extension "${operation.name}"`);
|
|
293
|
+
case 'create_table':
|
|
294
|
+
return `DROP TABLE ${quote(operation.table)}`;
|
|
295
|
+
case 'drop_table':
|
|
296
|
+
throw unsupported(
|
|
297
|
+
name,
|
|
298
|
+
`recreating dropped table "${operation.table}"`,
|
|
299
|
+
`the drop operation for "${operation.table}" carries no columns; write the down migration explicitly`,
|
|
300
|
+
);
|
|
301
|
+
case 'add_column':
|
|
302
|
+
return `ALTER TABLE ${quote(operation.table)} DROP COLUMN ${quote(operation.column.name)}`;
|
|
303
|
+
case 'drop_column':
|
|
304
|
+
throw unsupported(
|
|
305
|
+
name,
|
|
306
|
+
`recreating dropped column "${operation.table}"."${operation.column}"`,
|
|
307
|
+
'the drop operation carries no type or nullability; write the down migration explicitly',
|
|
308
|
+
);
|
|
309
|
+
case 'alter_column_type':
|
|
310
|
+
return (
|
|
311
|
+
`ALTER TABLE ${quote(operation.table)} MODIFY COLUMN ${quote(operation.column)} ` +
|
|
312
|
+
alteredType(name, types, operation, 'down')
|
|
313
|
+
);
|
|
314
|
+
case 'alter_primary_key':
|
|
315
|
+
return alterPrimaryKey(operation.table, operation.to, operation.from);
|
|
316
|
+
case 'add_foreign_key':
|
|
317
|
+
return dropForeignKey(operation.table, operation.fk.name, true);
|
|
318
|
+
case 'drop_foreign_key':
|
|
319
|
+
throw unsupported(
|
|
320
|
+
name,
|
|
321
|
+
`recreating foreign key "${operation.name}"`,
|
|
322
|
+
'the drop operation carries no columns or referential actions; write the down migration explicitly',
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function indexColumn<Name extends string>(name: Name, definition: IndexDef, column: IndexColumn): string {
|
|
328
|
+
if (typeof column === 'string') return quote(column);
|
|
329
|
+
if ('expr' in column) {
|
|
330
|
+
throw unsupported(
|
|
331
|
+
name,
|
|
332
|
+
`expression index "${definition.name}"`,
|
|
333
|
+
`${name} does not support an expression index ("${definition.name}" on "${definition.table}" uses ${column.expr}); add a generated column and index that instead`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (column.opclass !== undefined) {
|
|
337
|
+
throw unsupported(name, `index operator class ${column.opclass}`);
|
|
338
|
+
}
|
|
339
|
+
return quote(column.column);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function createIndex<Name extends string>(name: Name, definition: IndexDef): string {
|
|
343
|
+
if (definition.where !== undefined) {
|
|
344
|
+
throw unsupported(
|
|
345
|
+
name,
|
|
346
|
+
`partial index "${definition.name}"`,
|
|
347
|
+
`${name} does not support the partial index "${definition.name}" because MySQL has no predicate-index syntax`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (definition.with !== undefined && Object.keys(definition.with).length > 0) {
|
|
351
|
+
throw unsupported(name, `index options on "${definition.name}"`);
|
|
352
|
+
}
|
|
353
|
+
const method = definition.method;
|
|
354
|
+
if (method !== undefined && method !== 'btree' && method !== 'hash') {
|
|
355
|
+
throw unsupported(name, `index method ${method}`);
|
|
356
|
+
}
|
|
357
|
+
if (definition.columns.length === 0) throw new TypeError(`index "${definition.name}" must name a column`);
|
|
358
|
+
const unique = definition.unique === true ? 'UNIQUE ' : '';
|
|
359
|
+
const using = method === undefined ? '' : ` USING ${method.toUpperCase()}`;
|
|
360
|
+
return (
|
|
361
|
+
`CREATE ${unique}INDEX ${quote(definition.name)}${using} ON ${quote(definition.table)} ` +
|
|
362
|
+
`(${definition.columns.map(column => indexColumn(name, definition, column)).join(', ')})`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function routineType(types: DialectTypeMap, type: RoutineSqlType): string {
|
|
367
|
+
const mapped = Reflect.get(types, type);
|
|
368
|
+
return typeof mapped === 'string' ? (type === 'serial' ? 'INT' : mapped) : type;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function routineLabel(definition: RoutineDef): string {
|
|
372
|
+
return `${definition.kind} ${quote(definition.name)}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function assertRoutine<Name extends string>(name: Name, definition: RoutineDef): void {
|
|
376
|
+
for (const parameter of definition.params) {
|
|
377
|
+
if (parameter.mode === 'out' || parameter.mode === 'inout') {
|
|
378
|
+
throw unsupported(name, `${routineLabel(definition)} has unsupported ${parameter.mode} parameter`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (definition.language !== undefined) {
|
|
382
|
+
throw unsupported(
|
|
383
|
+
name,
|
|
384
|
+
`${routineLabel(definition)} cannot declare language ${JSON.stringify(definition.language)}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
if (definition.kind === 'procedure' && definition.returns !== undefined) {
|
|
388
|
+
throw new TypeError(`${routineLabel(definition)} cannot declare a return type`);
|
|
389
|
+
}
|
|
390
|
+
if (definition.kind === 'function') {
|
|
391
|
+
if (definition.returns === undefined) throw new TypeError(`${routineLabel(definition)} must declare a return type`);
|
|
392
|
+
if (definition.returns.setof === true || definition.returns.type === 'void') {
|
|
393
|
+
throw unsupported(name, `${routineLabel(definition)} cannot return a set or void`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function createRoutine<Name extends string>(name: Name, types: DialectTypeMap, definition: RoutineDef): string {
|
|
399
|
+
assertRoutine(name, definition);
|
|
400
|
+
const parameters = definition.params
|
|
401
|
+
.map(parameter => `${quote(parameter.name)} ${routineType(types, parameter.type)}`)
|
|
402
|
+
.join(', ');
|
|
403
|
+
const returnType = definition.kind === 'function' ? definition.returns?.type : undefined;
|
|
404
|
+
if (returnType === 'void') throw unsupported(name, `${routineLabel(definition)} cannot return void`);
|
|
405
|
+
const returns = returnType === undefined ? '' : ` RETURNS ${routineType(types, returnType)}`;
|
|
406
|
+
const deterministic =
|
|
407
|
+
definition.kind === 'function'
|
|
408
|
+
? ` ${definition.deterministic === true ? 'DETERMINISTIC' : 'NOT DETERMINISTIC'}`
|
|
409
|
+
: '';
|
|
410
|
+
return (
|
|
411
|
+
`CREATE ${definition.kind.toUpperCase()} ${quote(definition.name)}(${parameters})${returns}${deterministic} ` +
|
|
412
|
+
`MODIFIES SQL DATA SQL SECURITY INVOKER\n${definition.body}`
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function dropRoutine<Name extends string>(name: Name, definition: RoutineDef): string {
|
|
417
|
+
assertRoutine(name, definition);
|
|
418
|
+
return `DROP ${definition.kind.toUpperCase()} IF EXISTS ${quote(definition.name)}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function emitSchemaObject<Name extends string>(
|
|
422
|
+
name: Name,
|
|
423
|
+
types: DialectTypeMap,
|
|
424
|
+
operation: SchemaObjectOperation,
|
|
425
|
+
): readonly string[] {
|
|
426
|
+
switch (operation.kind) {
|
|
427
|
+
case 'create_index':
|
|
428
|
+
return [createIndex(name, operation.definition)];
|
|
429
|
+
case 'check_constraint':
|
|
430
|
+
return [
|
|
431
|
+
`ALTER TABLE ${quote(operation.table)} ADD CONSTRAINT ${quote(operation.name)} CHECK (${operation.expression})`,
|
|
432
|
+
];
|
|
433
|
+
case 'create_view':
|
|
434
|
+
if (operation.definition.materialized === true) throw unsupported(name, 'materialized views');
|
|
435
|
+
return [`CREATE VIEW ${quote(operation.definition.name)} AS ${operation.definition.select}`];
|
|
436
|
+
case 'drop_view':
|
|
437
|
+
if (operation.materialized === true) throw unsupported(name, 'materialized views');
|
|
438
|
+
return [`DROP VIEW IF EXISTS ${quote(operation.name)}`];
|
|
439
|
+
case 'create_sequence':
|
|
440
|
+
throw unsupported(name, `sequence "${operation.definition.name}"`);
|
|
441
|
+
case 'generated_column':
|
|
442
|
+
return [
|
|
443
|
+
`${quote(operation.definition.name)} ${operation.definition.type} GENERATED ALWAYS AS ` +
|
|
444
|
+
`(${operation.definition.expression})${operation.definition.stored === true ? ' STORED' : ''}`,
|
|
445
|
+
];
|
|
446
|
+
case 'create_schema':
|
|
447
|
+
return [`CREATE SCHEMA ${quote(operation.name)}`];
|
|
448
|
+
case 'enable_rls':
|
|
449
|
+
case 'create_policy':
|
|
450
|
+
throw unsupported(name, 'row-level security');
|
|
451
|
+
case 'create_extension':
|
|
452
|
+
throw unsupported(name, `extension "${operation.definition.name}"`);
|
|
453
|
+
case 'create_routine':
|
|
454
|
+
return [createRoutine(name, types, operation.definition)];
|
|
455
|
+
case 'drop_routine':
|
|
456
|
+
return [dropRoutine(name, operation.definition)];
|
|
457
|
+
case 'replace_routine':
|
|
458
|
+
return [dropRoutine(name, operation.previous ?? operation.next), createRoutine(name, types, operation.next)];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function validateSnapshot<Name extends string>(
|
|
463
|
+
name: Name,
|
|
464
|
+
types: DialectTypeMap,
|
|
465
|
+
tableExtension: MysqlTableDdlExtension | undefined,
|
|
466
|
+
snapshot: SchemaSnapshot,
|
|
467
|
+
): void {
|
|
468
|
+
if (snapshot.extensions.length > 0) {
|
|
469
|
+
throw unsupported(name, `extension "${snapshot.extensions[0]?.name ?? 'unknown'}"`);
|
|
470
|
+
}
|
|
471
|
+
for (const table of snapshot.tables) {
|
|
472
|
+
if (table.tableOptions !== undefined && tableExtension === undefined) {
|
|
473
|
+
throw unsupported(name, `table options on "${table.name}"`);
|
|
474
|
+
}
|
|
475
|
+
for (const column of table.columns) ddlType(name, types, column);
|
|
476
|
+
for (const foreignKey of table.foreignKeys) foreignKeyConstraint(name, foreignKey);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function splitGeneratedStatements(sql: string): readonly string[] {
|
|
481
|
+
if (!sql.startsWith('CREATE INDEX ') && !sql.startsWith('ALTER TABLE ')) return [sql];
|
|
482
|
+
let quoteCharacter: "'" | '"' | '`' | undefined;
|
|
483
|
+
for (let index = 0; index < sql.length; index += 1) {
|
|
484
|
+
const character = sql[index];
|
|
485
|
+
if (quoteCharacter !== undefined) {
|
|
486
|
+
if (character === quoteCharacter) {
|
|
487
|
+
if (sql[index + 1] === quoteCharacter) index += 1;
|
|
488
|
+
else quoteCharacter = undefined;
|
|
489
|
+
}
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (character === "'" || character === '"' || character === '`') {
|
|
493
|
+
quoteCharacter = character;
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (character !== ';') continue;
|
|
497
|
+
const first = sql.slice(0, index).trim();
|
|
498
|
+
const second = sql.slice(index + 1).trim();
|
|
499
|
+
const generatedPair =
|
|
500
|
+
(first.startsWith('CREATE INDEX ') && /^ALTER TABLE .* ADD CONSTRAINT /u.test(second)) ||
|
|
501
|
+
(first.startsWith('ALTER TABLE ') && first.includes(' DROP FOREIGN KEY ') && second.startsWith('DROP INDEX '));
|
|
502
|
+
return generatedPair ? [first, second] : [sql];
|
|
503
|
+
}
|
|
504
|
+
return [sql];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function migrationConnection<Name extends string>(
|
|
508
|
+
name: Name,
|
|
509
|
+
driver: MigrationDriver<Name>,
|
|
510
|
+
options: MigrationTableOptions = {},
|
|
511
|
+
ledger: MysqlMigrationOverrides['ledger'],
|
|
512
|
+
): MigrationConnection<Name> {
|
|
513
|
+
const tableName = options.table ?? '_zmdb_migrations';
|
|
514
|
+
const table = options.schema === undefined ? quote(tableName) : `${quote(options.schema)}.${quote(tableName)}`;
|
|
515
|
+
|
|
516
|
+
async function execute(
|
|
517
|
+
text: string,
|
|
518
|
+
parameters: readonly unknown[] = [],
|
|
519
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
520
|
+
return driver.execute({ text, parameters });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function appliedMigrations(): Promise<readonly AppliedMigration[]> {
|
|
524
|
+
const rows = await execute(`SELECT version, name, checksum FROM ${table} ORDER BY version`);
|
|
525
|
+
return rows.map((row, index) => {
|
|
526
|
+
const numericVersion = Number(row.version);
|
|
527
|
+
if (!Number.isSafeInteger(numericVersion) || typeof row.name !== 'string') {
|
|
528
|
+
throw new TypeError(`migration ledger row ${String(index)} has an invalid version or name`);
|
|
529
|
+
}
|
|
530
|
+
const checksum = row.checksum;
|
|
531
|
+
if (checksum !== null && typeof checksum !== 'string') {
|
|
532
|
+
throw new TypeError(`migration ledger row ${String(index)} has an invalid checksum`);
|
|
533
|
+
}
|
|
534
|
+
return { version: numericVersion, name: row.name, checksum };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const connection: MigrationConnection<Name> = {
|
|
539
|
+
name,
|
|
540
|
+
transactionalDdl: false,
|
|
541
|
+
async exec(sql) {
|
|
542
|
+
for (const statement of splitGeneratedStatements(sql)) await execute(statement);
|
|
543
|
+
},
|
|
544
|
+
async appliedVersions() {
|
|
545
|
+
return (await appliedMigrations()).map(row => row.version);
|
|
546
|
+
},
|
|
547
|
+
appliedMigrations,
|
|
548
|
+
async recordApplied(version, migrationName, checksum) {
|
|
549
|
+
await execute(`INSERT INTO ${table} (version, name, applied_at, checksum) VALUES (?, ?, ?, ?)`, [
|
|
550
|
+
version,
|
|
551
|
+
migrationName,
|
|
552
|
+
Date.now(),
|
|
553
|
+
checksum ?? null,
|
|
554
|
+
]);
|
|
555
|
+
},
|
|
556
|
+
async recordReverted(version) {
|
|
557
|
+
await execute(`DELETE FROM ${table} WHERE version = ?`, [version]);
|
|
558
|
+
},
|
|
559
|
+
async ensureVersionTable() {
|
|
560
|
+
const createPrefix = ledger?.createPrefix ?? 'CREATE TABLE';
|
|
561
|
+
const extraDefinitions =
|
|
562
|
+
ledger?.definitions === undefined || ledger.definitions.length === 0
|
|
563
|
+
? ''
|
|
564
|
+
: `, ${ledger.definitions.join(', ')}`;
|
|
565
|
+
await execute(
|
|
566
|
+
`${createPrefix} IF NOT EXISTS ${table} (` +
|
|
567
|
+
`version BIGINT PRIMARY KEY, name TEXT NOT NULL, applied_at BIGINT NOT NULL, checksum TEXT${extraDefinitions})`,
|
|
568
|
+
);
|
|
569
|
+
await execute(`ALTER TABLE ${table} MODIFY COLUMN version BIGINT NOT NULL`);
|
|
570
|
+
try {
|
|
571
|
+
await execute(`SELECT checksum FROM ${table} WHERE 1 = 0`);
|
|
572
|
+
} catch {
|
|
573
|
+
await execute(`ALTER TABLE ${table} ADD COLUMN checksum TEXT`);
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
async checksum(sql) {
|
|
577
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(sql));
|
|
578
|
+
return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('');
|
|
579
|
+
},
|
|
580
|
+
transaction<Result>(run: (nested?: MigrationConnection<Name>) => Promise<Result>): Promise<Result> {
|
|
581
|
+
return run(connection);
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
return connection;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function resolvedTypes(overrides: MysqlMigrationOverrides): DialectTypeMap {
|
|
588
|
+
return Object.freeze({
|
|
589
|
+
...TYPES,
|
|
590
|
+
...overrides.types,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function mysqlFamilyMigrations<Name extends string>(
|
|
595
|
+
name: Name,
|
|
596
|
+
overrides: MysqlMigrationOverrides = {},
|
|
597
|
+
): MigrationDialect<Name> {
|
|
598
|
+
const types = resolvedTypes(overrides);
|
|
599
|
+
const tableExtension =
|
|
600
|
+
overrides.table === undefined
|
|
601
|
+
? undefined
|
|
602
|
+
: Object.freeze({
|
|
603
|
+
createPrefix: overrides.table.createPrefix,
|
|
604
|
+
definitions: overrides.table.definitions,
|
|
605
|
+
});
|
|
606
|
+
const ledger =
|
|
607
|
+
overrides.ledger === undefined
|
|
608
|
+
? undefined
|
|
609
|
+
: Object.freeze({
|
|
610
|
+
createPrefix: overrides.ledger.createPrefix,
|
|
611
|
+
...(overrides.ledger.definitions === undefined
|
|
612
|
+
? {}
|
|
613
|
+
: { definitions: Object.freeze([...overrides.ledger.definitions]) }),
|
|
614
|
+
});
|
|
615
|
+
const migrations: MigrationDialect<Name> = {
|
|
616
|
+
name,
|
|
617
|
+
foreignKeyMode: 'deferred',
|
|
618
|
+
embedded: false,
|
|
619
|
+
validateSnapshot: (snapshot: SchemaSnapshot) => validateSnapshot(name, types, tableExtension, snapshot),
|
|
620
|
+
validatePlan(plan: MigrationPlan) {
|
|
621
|
+
validateSnapshot(name, types, tableExtension, plan.before);
|
|
622
|
+
validateSnapshot(name, types, tableExtension, plan.after);
|
|
623
|
+
for (const operation of plan.operations) emitUp(name, types, tableExtension, operation);
|
|
624
|
+
},
|
|
625
|
+
ddlType: (column: ColumnSnapshot) => ddlType(name, types, column),
|
|
626
|
+
emitUp: (operation: ChangeOp) => emitUp(name, types, tableExtension, operation),
|
|
627
|
+
emitDown: (operation: ChangeOp) => emitDown(name, types, operation),
|
|
628
|
+
emitSchemaObject: (operation: SchemaObjectOperation) => emitSchemaObject(name, types, operation),
|
|
629
|
+
connection: (driver: MigrationDriver<Name>, options?: MigrationTableOptions) =>
|
|
630
|
+
migrationConnection(name, driver, options, ledger),
|
|
631
|
+
};
|
|
632
|
+
return Object.freeze(migrations);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function createMysqlMigrations<Name extends string>(name: Name): MigrationDialect<Name> {
|
|
636
|
+
return mysqlFamilyMigrations(name);
|
|
637
|
+
}
|