@zmdb/mssql 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.
@@ -0,0 +1,405 @@
1
+ import type { ChangeOp, ColumnSnapshot, ForeignKeySnapshot, SchemaSnapshot } from '@zmdb/migrations';
2
+ import {
3
+ quoteIdentifier,
4
+ quoteTable,
5
+ UnsupportedFeatureError,
6
+ type AppliedMigration,
7
+ type MigrationConnection,
8
+ type MigrationDialect,
9
+ type MigrationDriver,
10
+ type MigrationPlan,
11
+ type MigrationTableOptions,
12
+ type SchemaObjectOperation,
13
+ } from '@zmdb/sql';
14
+ import { type GeneratedColumn, type IndexColumn, type IndexDef, type SequenceDef } from '@zmdb/sql/schema-objects';
15
+
16
+ import { mssqlDdlType } from './types.js';
17
+
18
+ function identifier(name: string): string {
19
+ return `[${name.replaceAll(']', ']]')}]`;
20
+ }
21
+
22
+ function table(name: string): string {
23
+ return name
24
+ .split('.')
25
+ .map(part => identifier(part))
26
+ .join('.');
27
+ }
28
+
29
+ function safeVersion(value: unknown, row: number): number {
30
+ if (typeof value !== 'number' && typeof value !== 'bigint' && typeof value !== 'string') {
31
+ throw new TypeError(`SQL Server migration ledger row ${String(row)} has an invalid version`);
32
+ }
33
+ const version = Number(value);
34
+ if (!Number.isSafeInteger(version)) {
35
+ throw new TypeError(`SQL Server migration ledger row ${String(row)} version is not a safe integer`);
36
+ }
37
+ return version;
38
+ }
39
+
40
+ function appliedMigration(row: Readonly<Record<string, unknown>>, index: number): AppliedMigration {
41
+ const name = Reflect.get(row, 'name');
42
+ const migrationChecksum = Reflect.get(row, 'checksum');
43
+ if (typeof name !== 'string' || (migrationChecksum !== null && typeof migrationChecksum !== 'string')) {
44
+ throw new TypeError(`SQL Server migration ledger row ${String(index)} has an invalid name or checksum`);
45
+ }
46
+ return {
47
+ version: safeVersion(Reflect.get(row, 'version'), index),
48
+ name,
49
+ checksum: migrationChecksum,
50
+ };
51
+ }
52
+
53
+ async function checksum(sql: string): Promise<string> {
54
+ const bytes = new TextEncoder().encode(sql);
55
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest('SHA-256', bytes));
56
+ return `sha256:${Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('')}`;
57
+ }
58
+
59
+ function migrationConnection(
60
+ driver: MigrationDriver<'mssql'>,
61
+ options: MigrationTableOptions = {},
62
+ ): MigrationConnection<'mssql'> {
63
+ const dialect = driver.dialect;
64
+ const tableName = options.table ?? '_zmdb_migrations';
65
+ const qualifiedTable = options.schema === undefined ? tableName : `${options.schema}.${tableName}`;
66
+ const ledgerTable = quoteTable(dialect, qualifiedTable);
67
+
68
+ async function execute(
69
+ text: string,
70
+ parameters: readonly unknown[] = [],
71
+ ): Promise<readonly Record<string, unknown>[]> {
72
+ return driver.execute({ text, parameters });
73
+ }
74
+
75
+ async function appliedMigrations(): Promise<readonly AppliedMigration[]> {
76
+ const rows = await execute(`SELECT version, name, checksum FROM ${ledgerTable} ORDER BY version`);
77
+ return rows.map(appliedMigration);
78
+ }
79
+
80
+ return {
81
+ name: 'mssql',
82
+ transactionalDdl: true,
83
+ async exec(sql): Promise<void> {
84
+ await execute(sql);
85
+ },
86
+ async appliedVersions(): Promise<readonly number[]> {
87
+ return (await appliedMigrations()).map(migration => migration.version);
88
+ },
89
+ appliedMigrations,
90
+ async recordApplied(version, name, migrationChecksum = ''): Promise<void> {
91
+ await execute(
92
+ `INSERT INTO ${ledgerTable} (` +
93
+ `${quoteIdentifier(dialect, 'version')}, ${quoteIdentifier(dialect, 'name')}, ` +
94
+ `${quoteIdentifier(dialect, 'applied_at')}, ${quoteIdentifier(dialect, 'checksum')}) ` +
95
+ 'VALUES (@p1, @p2, @p3, @p4)',
96
+ [version, name, Date.now(), migrationChecksum],
97
+ );
98
+ },
99
+ async recordReverted(version): Promise<void> {
100
+ await execute(`DELETE FROM ${ledgerTable} WHERE ${quoteIdentifier(dialect, 'version')} = @p1`, [version]);
101
+ },
102
+ async ensureVersionTable(): Promise<void> {
103
+ const objectName = qualifiedTable.replaceAll("'", "''");
104
+ await execute(
105
+ `IF OBJECT_ID(N'${objectName}', N'U') IS NULL ` +
106
+ `CREATE TABLE ${ledgerTable} (` +
107
+ `${quoteIdentifier(dialect, 'version')} BIGINT PRIMARY KEY, ` +
108
+ `${quoteIdentifier(dialect, 'name')} NVARCHAR(MAX) NOT NULL, ` +
109
+ `${quoteIdentifier(dialect, 'applied_at')} BIGINT NOT NULL, ` +
110
+ `${quoteIdentifier(dialect, 'checksum')} NVARCHAR(MAX))`,
111
+ );
112
+ try {
113
+ await execute(`SELECT ${quoteIdentifier(dialect, 'checksum')} FROM ${ledgerTable} WHERE 1 = 0`);
114
+ } catch {
115
+ await execute(`ALTER TABLE ${ledgerTable} ADD ${quoteIdentifier(dialect, 'checksum')} NVARCHAR(MAX)`);
116
+ }
117
+ },
118
+ checksum,
119
+ async transaction<Result>(run: (connection?: MigrationConnection<'mssql'>) => Promise<Result>): Promise<Result> {
120
+ if (driver.transaction === undefined) {
121
+ throw new Error(
122
+ 'mssql migrations require a transactional driver; every callback request must come from one node-mssql transaction',
123
+ );
124
+ }
125
+ return driver.transaction(transactionDriver => run(migrationConnection(transactionDriver, options)));
126
+ },
127
+ };
128
+ }
129
+
130
+ function columnDdl(
131
+ column: ColumnSnapshot,
132
+ options: { readonly inlinePrimaryKey: boolean; readonly tablePrimaryKey: boolean },
133
+ ): string {
134
+ const primaryKey = options.inlinePrimaryKey ? ' PRIMARY KEY' : '';
135
+ const notNull = !options.inlinePrimaryKey && (!column.nullable || options.tablePrimaryKey) ? ' NOT NULL' : '';
136
+ return `${identifier(column.name)} ${mssqlDdlType(column)}${primaryKey}${notNull}`;
137
+ }
138
+
139
+ function action(actionName: ForeignKeySnapshot['onDelete']): string {
140
+ return actionName === 'restrict' || actionName === 'no action' ? 'NO ACTION' : actionName.toUpperCase();
141
+ }
142
+
143
+ function foreignKeyDdl(foreignKey: ForeignKeySnapshot): string {
144
+ const columns = foreignKey.columns.map(identifier).join(', ');
145
+ const targetColumns = foreignKey.targetColumns.map(identifier).join(', ');
146
+ return (
147
+ `FOREIGN KEY (${columns}) REFERENCES ${table(foreignKey.targetTable)} (${targetColumns}) ` +
148
+ `ON DELETE ${action(foreignKey.onDelete)} ON UPDATE ${action(foreignKey.onUpdate)}`
149
+ );
150
+ }
151
+
152
+ function createTable(operation: Extract<ChangeOp, { readonly kind: 'create_table' }>): string {
153
+ const inline = operation.primaryKey.length === 1 ? operation.primaryKey[0] : undefined;
154
+ const composite = operation.primaryKey.length > 1 ? new Set(operation.primaryKey) : undefined;
155
+ const definitions = operation.columns.map(column =>
156
+ columnDdl(column, {
157
+ inlinePrimaryKey: column.name === inline,
158
+ tablePrimaryKey: composite?.has(column.name) === true,
159
+ }),
160
+ );
161
+ if (composite !== undefined) {
162
+ definitions.push(`PRIMARY KEY (${operation.primaryKey.map(identifier).join(', ')})`);
163
+ }
164
+ return `CREATE TABLE ${table(operation.table)} (${definitions.join(', ')})`;
165
+ }
166
+
167
+ function alterNullability(
168
+ operation: Extract<ChangeOp, { readonly kind: 'alter_column_type' }>,
169
+ direction: 'up' | 'down',
170
+ ): string {
171
+ const nullable = direction === 'up' ? operation.toNullable : operation.fromNullable;
172
+ if (nullable === undefined) {
173
+ throw new UnsupportedFeatureError(
174
+ `altering the type of "${operation.table}"."${operation.column}" without nullability metadata`,
175
+ 'mssql',
176
+ 'mssql ALTER COLUMN must restate NULL or NOT NULL; generate this operation from snapshots or provide ' +
177
+ `${direction === 'up' ? 'toNullable' : 'fromNullable'} explicitly`,
178
+ );
179
+ }
180
+ return nullable ? ' NULL' : ' NOT NULL';
181
+ }
182
+
183
+ function alteredType(
184
+ operation: Extract<ChangeOp, { readonly kind: 'alter_column_type' }>,
185
+ direction: 'up' | 'down',
186
+ ): string {
187
+ return mssqlDdlType({
188
+ name: operation.column,
189
+ type: direction === 'up' ? operation.to : operation.from,
190
+ nullable: direction === 'up' ? (operation.toNullable ?? true) : (operation.fromNullable ?? true),
191
+ primaryKey: false,
192
+ });
193
+ }
194
+
195
+ function primaryKeyRefusal(operation: Extract<ChangeOp, { readonly kind: 'alter_primary_key' }>): never {
196
+ throw new UnsupportedFeatureError(
197
+ `altering the primary key of "${operation.table}"`,
198
+ 'mssql',
199
+ `mssql cannot safely alter the primary key of "${operation.table}" ` +
200
+ `(${operation.from.join(', ')} → ${operation.to.join(', ')}) because the snapshot does not carry the ` +
201
+ 'existing SQL Server constraint name; use a hand-written migration',
202
+ );
203
+ }
204
+
205
+ function emitUp(operation: ChangeOp): string {
206
+ switch (operation.kind) {
207
+ case 'create_extension':
208
+ throw new UnsupportedFeatureError(
209
+ `extension "${operation.name}"`,
210
+ 'mssql',
211
+ `mssql does not support database extensions ("${operation.name}")`,
212
+ );
213
+ case 'create_table':
214
+ return createTable(operation);
215
+ case 'drop_table':
216
+ return `DROP TABLE ${table(operation.table)}`;
217
+ case 'add_column':
218
+ return `ALTER TABLE ${table(operation.table)} ADD ${columnDdl(operation.column, {
219
+ inlinePrimaryKey: operation.column.primaryKey,
220
+ tablePrimaryKey: false,
221
+ })}`;
222
+ case 'drop_column':
223
+ return `ALTER TABLE ${table(operation.table)} DROP COLUMN ${identifier(operation.column)}`;
224
+ case 'alter_column_type':
225
+ return (
226
+ `ALTER TABLE ${table(operation.table)} ALTER COLUMN ${identifier(operation.column)} ` +
227
+ `${alteredType(operation, 'up')}${alterNullability(operation, 'up')}`
228
+ );
229
+ case 'alter_primary_key':
230
+ return primaryKeyRefusal(operation);
231
+ case 'add_foreign_key':
232
+ return (
233
+ `ALTER TABLE ${table(operation.table)} ADD CONSTRAINT ${identifier(operation.fk.name)} ` +
234
+ foreignKeyDdl(operation.fk)
235
+ );
236
+ case 'drop_foreign_key':
237
+ return `ALTER TABLE ${table(operation.table)} DROP CONSTRAINT ${identifier(operation.name)}`;
238
+ }
239
+ }
240
+
241
+ function emitDown(operation: ChangeOp): string {
242
+ switch (operation.kind) {
243
+ case 'create_extension':
244
+ throw new UnsupportedFeatureError(`reverting extension "${operation.name}"`, 'mssql');
245
+ case 'create_table':
246
+ return `DROP TABLE ${table(operation.table)}`;
247
+ case 'drop_table':
248
+ throw new UnsupportedFeatureError(
249
+ `recreating dropped table "${operation.table}"`,
250
+ 'mssql',
251
+ `mssql cannot recreate dropped table "${operation.table}" because the drop operation carries no columns; ` +
252
+ 'write the down migration by hand',
253
+ );
254
+ case 'add_column':
255
+ return `ALTER TABLE ${table(operation.table)} DROP COLUMN ${identifier(operation.column.name)}`;
256
+ case 'drop_column':
257
+ throw new UnsupportedFeatureError(
258
+ `recreating dropped column "${operation.table}"."${operation.column}"`,
259
+ 'mssql',
260
+ 'the drop operation carries no SQL type or nullability; write the down migration by hand',
261
+ );
262
+ case 'alter_column_type':
263
+ return (
264
+ `ALTER TABLE ${table(operation.table)} ALTER COLUMN ${identifier(operation.column)} ` +
265
+ `${alteredType(operation, 'down')}${alterNullability(operation, 'down')}`
266
+ );
267
+ case 'alter_primary_key':
268
+ return primaryKeyRefusal({
269
+ ...operation,
270
+ from: operation.to,
271
+ to: operation.from,
272
+ });
273
+ case 'add_foreign_key':
274
+ return `ALTER TABLE ${table(operation.table)} DROP CONSTRAINT ${identifier(operation.fk.name)}`;
275
+ case 'drop_foreign_key':
276
+ throw new UnsupportedFeatureError(
277
+ `recreating foreign key "${operation.name}" on "${operation.table}"`,
278
+ 'mssql',
279
+ 'the drop operation carries no columns, target or referential actions; write the down migration by hand',
280
+ );
281
+ }
282
+ }
283
+
284
+ function indexColumn(column: IndexColumn, definition: IndexDef): string {
285
+ if (typeof column === 'string') return identifier(column);
286
+ if ('expr' in column) {
287
+ throw new UnsupportedFeatureError(
288
+ `expression index "${definition.name}"`,
289
+ 'mssql',
290
+ `mssql does not support an expression index ("${definition.name}" on "${definition.table}" uses ` +
291
+ `${column.expr}); add a computed column and index that instead`,
292
+ );
293
+ }
294
+ if (column.opclass !== undefined) {
295
+ throw new UnsupportedFeatureError(`index operator class ${column.opclass}`, 'mssql');
296
+ }
297
+ return identifier(column.column);
298
+ }
299
+
300
+ function createIndex(definition: IndexDef): string {
301
+ if (definition.method !== undefined && definition.method !== 'btree') {
302
+ throw new UnsupportedFeatureError(
303
+ `index method ${definition.method}`,
304
+ 'mssql',
305
+ `mssql does not support the index method ${definition.method} ("${definition.name}" on "${definition.table}")`,
306
+ );
307
+ }
308
+ if (definition.with !== undefined && Object.keys(definition.with).length > 0) {
309
+ throw new UnsupportedFeatureError(
310
+ `index options on "${definition.name}"`,
311
+ 'mssql',
312
+ 'the current SQL Server index contract does not model WITH options',
313
+ );
314
+ }
315
+ const unique = definition.unique === true ? 'UNIQUE ' : '';
316
+ const columns = definition.columns.map(column => indexColumn(column, definition)).join(', ');
317
+ const predicate = definition.where === undefined ? '' : ` WHERE ${definition.where}`;
318
+ return `CREATE ${unique}INDEX ${identifier(definition.name)} ON ${table(definition.table)} (${columns})${predicate}`;
319
+ }
320
+
321
+ function sequence(definition: SequenceDef): string {
322
+ let sql = `CREATE SEQUENCE ${table(definition.name)}`;
323
+ if (definition.start !== undefined) sql += ` START WITH ${String(definition.start)}`;
324
+ if (definition.increment !== undefined) sql += ` INCREMENT BY ${String(definition.increment)}`;
325
+ return sql;
326
+ }
327
+
328
+ function generatedColumn(definition: GeneratedColumn): string {
329
+ return `${identifier(definition.name)} AS (${definition.expression})${definition.stored === true ? ' PERSISTED' : ''}`;
330
+ }
331
+
332
+ function unsupportedSchemaObject(feature: string, detail?: string): never {
333
+ throw new UnsupportedFeatureError(feature, 'mssql', detail);
334
+ }
335
+
336
+ function emitSchemaObject(operation: SchemaObjectOperation): readonly string[] {
337
+ switch (operation.kind) {
338
+ case 'create_index':
339
+ return [createIndex(operation.definition)];
340
+ case 'check_constraint':
341
+ return [
342
+ `ALTER TABLE ${table(operation.table)} ADD CONSTRAINT ${identifier(operation.name)} ` +
343
+ `CHECK (${operation.expression})`,
344
+ ];
345
+ case 'create_view':
346
+ if (operation.definition.materialized === true) {
347
+ return unsupportedSchemaObject(
348
+ 'materialized views',
349
+ 'SQL Server indexed views require schema binding and index declarations that the current view contract does not carry',
350
+ );
351
+ }
352
+ return [`CREATE VIEW ${table(operation.definition.name)} AS ${operation.definition.select}`];
353
+ case 'drop_view':
354
+ if (operation.materialized === true) return unsupportedSchemaObject('materialized views');
355
+ return [`DROP VIEW IF EXISTS ${table(operation.name)}`];
356
+ case 'create_sequence':
357
+ return [sequence(operation.definition)];
358
+ case 'generated_column':
359
+ return [generatedColumn(operation.definition)];
360
+ case 'create_schema':
361
+ return [`CREATE SCHEMA ${identifier(operation.name)}`];
362
+ case 'enable_rls':
363
+ return unsupportedSchemaObject('row-level security');
364
+ case 'create_policy':
365
+ return unsupportedSchemaObject('row-level security policy');
366
+ case 'create_extension':
367
+ return unsupportedSchemaObject(
368
+ `extension "${operation.definition.name}"`,
369
+ `mssql does not support database extensions ("${operation.definition.name}")`,
370
+ );
371
+ case 'create_routine':
372
+ case 'drop_routine':
373
+ case 'replace_routine':
374
+ return unsupportedSchemaObject(
375
+ 'stored routines',
376
+ 'SQL Server CREATE/ALTER and EXEC grammar is not represented by the current routine contract; ' +
377
+ 'use a hand-written migration and driver call',
378
+ );
379
+ }
380
+ }
381
+
382
+ function validateSnapshot(snapshot: SchemaSnapshot): void {
383
+ for (const currentTable of snapshot.tables) {
384
+ for (const column of currentTable.columns) mssqlDdlType(column);
385
+ }
386
+ }
387
+
388
+ function validatePlan(plan: MigrationPlan): void {
389
+ validateSnapshot(plan.before);
390
+ validateSnapshot(plan.after);
391
+ for (const operation of plan.operations) emitUp(operation);
392
+ }
393
+
394
+ export const mssqlMigrations: MigrationDialect<'mssql'> = Object.freeze({
395
+ name: 'mssql',
396
+ foreignKeyMode: 'deferred',
397
+ embedded: false,
398
+ validateSnapshot,
399
+ validatePlan,
400
+ ddlType: mssqlDdlType,
401
+ emitUp,
402
+ emitDown,
403
+ emitSchemaObject,
404
+ connection: migrationConnection,
405
+ });
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ColumnSnapshot } from '@zmdb/migrations';
2
+ import { UnsupportedFeatureError, type DialectTypeMap } from '@zmdb/sql';
3
+
4
+ export const MSSQL_TYPES = Object.freeze({
5
+ serial: 'INT IDENTITY(1,1)',
6
+ integer: 'INT',
7
+ bigint: 'BIGINT',
8
+ numeric: 'DECIMAL',
9
+ text: 'NVARCHAR(MAX)',
10
+ varchar: 'NVARCHAR',
11
+ boolean: 'BIT',
12
+ timestamp: 'DATETIMEOFFSET(3)',
13
+ json: 'NVARCHAR(MAX)',
14
+ jsonEnum: 'NVARCHAR(MAX)',
15
+ } satisfies DialectTypeMap);
16
+
17
+ export function mssqlDdlType(column: ColumnSnapshot): string {
18
+ if (typeof column.type !== 'string') {
19
+ const args = column.type.args ?? [];
20
+ const rendered = `${column.type.name}${args.length === 0 ? '' : `(${args.map(String).join(',')})`}`;
21
+ throw new UnsupportedFeatureError(
22
+ `extension type ${rendered}`,
23
+ 'mssql',
24
+ `mssql does not support the extension type ${rendered} on column "${column.name}" ` +
25
+ `(extension \`${column.type.extension}\`); use a native SQL Server type in a hand-written migration`,
26
+ );
27
+ }
28
+
29
+ const mapped: string = Reflect.get(MSSQL_TYPES, column.type) ?? column.type;
30
+ if (column.type === 'varchar') {
31
+ return column.length === undefined ? 'NVARCHAR(MAX)' : `NVARCHAR(${String(column.length)})`;
32
+ }
33
+ return mapped;
34
+ }