db-migrate 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/api.js +18 -46
- package/lib/commands/generated.js +1 -0
- package/lib/commands/helper/seeder.js +29 -0
- package/lib/commands/run.js +11 -13
- package/lib/commands/seed.js +5 -9
- package/lib/commands/set-default-argv.js +4 -1
- package/lib/commands/undo-seed.js +5 -9
- package/lib/dml.js +758 -0
- package/lib/executors/versioned/v2.js +18 -78
- package/lib/recovery.js +83 -0
- package/lib/seed.js +199 -0
- package/package.json +4 -4
package/lib/dml.js
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const Promise = require('bluebird');
|
|
5
|
+
const log = require('db-migrate-shared').log;
|
|
6
|
+
const State = require('./state');
|
|
7
|
+
const { recoveryMode, describeFailure } = require('./recovery');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* DML migrations, _meta: { version: 2, type: 'dml' }, change data instead of
|
|
11
|
+
* the schema. Every instruction is a step, recorded with what reverts it:
|
|
12
|
+
*
|
|
13
|
+
* insert: the rows get the step in their __dbmigrate__flag column, reverting
|
|
14
|
+
* deletes the rows flagged with it
|
|
15
|
+
* update, delete: the rows are copied to a backup table in the same database
|
|
16
|
+
* first, reverting restores them from there. The rows are then
|
|
17
|
+
* changed in batches by their key, the progress is kept in the
|
|
18
|
+
* state, so an interrupted run continues where it stopped.
|
|
19
|
+
* runSql: reverted by the SQL given with { revert }
|
|
20
|
+
*
|
|
21
|
+
* The records are { t: 4, a: action, c: args }, or { t: 3 } for a step run
|
|
22
|
+
* with { irreversible: true }, which can not be reverted.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const FLAG = '__dbmigrate__flag';
|
|
26
|
+
const BATCH = 1000;
|
|
27
|
+
const TYPES = ['dml'];
|
|
28
|
+
|
|
29
|
+
const SCHEMA_INSTRUCTIONS = [
|
|
30
|
+
'createTable',
|
|
31
|
+
'dropTable',
|
|
32
|
+
'renameTable',
|
|
33
|
+
'addColumn',
|
|
34
|
+
'removeColumn',
|
|
35
|
+
'renameColumn',
|
|
36
|
+
'changeColumn',
|
|
37
|
+
'addIndex',
|
|
38
|
+
'removeIndex',
|
|
39
|
+
'addForeignKey',
|
|
40
|
+
'removeForeignKey',
|
|
41
|
+
'createCollection',
|
|
42
|
+
'dropCollection',
|
|
43
|
+
'renameCollection'
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const isDml = file => (file.get()._meta || {}).type === 'dml';
|
|
47
|
+
|
|
48
|
+
const checkType = file => {
|
|
49
|
+
const type = (file.get()._meta || {}).type;
|
|
50
|
+
|
|
51
|
+
if (type !== undefined && TYPES.indexOf(type) === -1) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid migration type "${type}" in migration "${file.name}", use ` +
|
|
54
|
+
TYPES.join(', ') +
|
|
55
|
+
' or leave it out for a schema migration'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const backupName = (file, op) =>
|
|
61
|
+
'__dbm_backup_' +
|
|
62
|
+
crypto
|
|
63
|
+
.createHash('sha256')
|
|
64
|
+
.update(`${file.name}#${op}`)
|
|
65
|
+
.digest('hex')
|
|
66
|
+
.slice(0, 16);
|
|
67
|
+
|
|
68
|
+
const isObject = x =>
|
|
69
|
+
x !== null &&
|
|
70
|
+
typeof x === 'object' &&
|
|
71
|
+
!Array.isArray(x) &&
|
|
72
|
+
!(x instanceof Date) &&
|
|
73
|
+
!Buffer.isBuffer(x);
|
|
74
|
+
|
|
75
|
+
// like db-migrate-base, objects and arrays are stored as JSON
|
|
76
|
+
const toValue = value => {
|
|
77
|
+
if (
|
|
78
|
+
value !== null &&
|
|
79
|
+
typeof value === 'object' &&
|
|
80
|
+
!(value instanceof Date) &&
|
|
81
|
+
!Buffer.isBuffer(value)
|
|
82
|
+
) {
|
|
83
|
+
return JSON.stringify(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return value === undefined ? null : value;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The rows of an insert as objects, from any form db-migrate-base takes,
|
|
91
|
+
* and the options following them.
|
|
92
|
+
*/
|
|
93
|
+
const insertRows = args => {
|
|
94
|
+
if (Array.isArray(args[0]) && Array.isArray(args[1])) {
|
|
95
|
+
const [columns, values, options = {}] = args;
|
|
96
|
+
const rows =
|
|
97
|
+
!values.length || Array.isArray(values[0]) ? values : [values];
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
options,
|
|
101
|
+
rows: rows.map(row => {
|
|
102
|
+
if (!Array.isArray(row) || row.length !== columns.length) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'The number of columns does not match the number of values.'
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const object = {};
|
|
109
|
+
columns.forEach((column, i) => {
|
|
110
|
+
object[column] = row[i];
|
|
111
|
+
});
|
|
112
|
+
return object;
|
|
113
|
+
})
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const [data, options = {}] = args;
|
|
118
|
+
|
|
119
|
+
if (isObject(data) && Array.isArray(data.columns)) {
|
|
120
|
+
const values = [];
|
|
121
|
+
for (let i = 0; i < data.data.length; i += data.columns.length) {
|
|
122
|
+
values.push(data.data.slice(i, i + data.columns.length));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return insertRows([data.columns, values, options]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (isObject(data)) {
|
|
129
|
+
return { rows: [data], options };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(data) && data.every(isObject)) {
|
|
133
|
+
return { rows: data, options };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error('insert needs the rows to insert');
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
class Dml {
|
|
140
|
+
constructor (context, file, internals, pdriver, recovery) {
|
|
141
|
+
this.driver = context._driver;
|
|
142
|
+
this.file = file;
|
|
143
|
+
this.internals = internals;
|
|
144
|
+
this.pdriver = pdriver;
|
|
145
|
+
this.recovery = recovery;
|
|
146
|
+
this.op = 0;
|
|
147
|
+
this.current = null;
|
|
148
|
+
|
|
149
|
+
SCHEMA_INSTRUCTIONS.forEach(method => {
|
|
150
|
+
this[method] = () => {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${method} is a schema instruction, which dml migrations do not ` +
|
|
153
|
+
'allow. Change the schema in a v2 migration of its own.'
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
ddl (name) {
|
|
160
|
+
return this.driver.escapeDDL(name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
columns (table) {
|
|
164
|
+
const t = this.internals.schema.c[table];
|
|
165
|
+
return t ? t.columns || t : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The key of a table, to change and restore its rows by. Taken from the
|
|
170
|
+
* schema, for other tables it is passed with { key }.
|
|
171
|
+
*/
|
|
172
|
+
key (table, options, action) {
|
|
173
|
+
if (options.key) {
|
|
174
|
+
return [].concat(options.key);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const columns = this.columns(table) || {};
|
|
178
|
+
const key = Object.keys(columns).filter(c => columns[c].primaryKey);
|
|
179
|
+
|
|
180
|
+
if (!key.length) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${action}("${table}") needs the primary key of "${table}" to revert ` +
|
|
183
|
+
'it, which is unknown to the schema of db-migrate. Pass it with ' +
|
|
184
|
+
"{ key: 'id' }, or pass { irreversible: true } to change the rows " +
|
|
185
|
+
'without being able to revert it.'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return key;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
where (where) {
|
|
193
|
+
if (typeof where === 'string') {
|
|
194
|
+
return { sql: where, params: [] };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (Array.isArray(where) && typeof where[0] === 'string') {
|
|
198
|
+
return { sql: where[0], params: where[1] || [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isObject(where)) {
|
|
202
|
+
const params = [];
|
|
203
|
+
const parts = Object.keys(where).map(column => {
|
|
204
|
+
const value = where[column];
|
|
205
|
+
|
|
206
|
+
if (value === null || value === undefined) {
|
|
207
|
+
return `${this.ddl(column)} IS NULL`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
if (!value.length) {
|
|
212
|
+
return '1 = 0';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
params.push(...value.map(toValue));
|
|
216
|
+
return `${this.ddl(column)} IN (${value.map(() => '?').join(', ')})`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
params.push(toValue(value));
|
|
220
|
+
return `${this.ddl(column)} = ?`;
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return { sql: parts.length ? parts.join(' AND ') : '1 = 1', params };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
throw new Error(
|
|
227
|
+
"where is an object like { status: 'old' }, a SQL string or " +
|
|
228
|
+
'[sql, params]'
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// matches the rows by their key
|
|
233
|
+
byKey (rows, key) {
|
|
234
|
+
const params = [];
|
|
235
|
+
|
|
236
|
+
if (key.length === 1) {
|
|
237
|
+
rows.forEach(row => params.push(row[key[0]]));
|
|
238
|
+
return {
|
|
239
|
+
sql: `${this.ddl(key[0])} IN (${rows.map(() => '?').join(', ')})`,
|
|
240
|
+
params
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sql = rows
|
|
245
|
+
.map(row => {
|
|
246
|
+
key.forEach(k => params.push(row[k]));
|
|
247
|
+
return '(' + key.map(k => `${this.ddl(k)} = ?`).join(' AND ') + ')';
|
|
248
|
+
})
|
|
249
|
+
.join(' OR ');
|
|
250
|
+
|
|
251
|
+
return { sql, params };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Runs one instruction as a step: records what reverts it, executes it and
|
|
256
|
+
* records it as done. The step of an interrupted run is continued, with
|
|
257
|
+
* the record it left.
|
|
258
|
+
*/
|
|
259
|
+
async step (action, args, record, execute) {
|
|
260
|
+
const op = ++this.op;
|
|
261
|
+
const internals = this.internals;
|
|
262
|
+
const mod = internals.modSchema;
|
|
263
|
+
const recovery = this.recovery;
|
|
264
|
+
this.current = `${action}(${args.map(a => JSON.stringify(a)).join(', ')})`;
|
|
265
|
+
internals.migrationOp = op;
|
|
266
|
+
|
|
267
|
+
if (recovery && op <= recovery.done) {
|
|
268
|
+
log.info(
|
|
269
|
+
`[recovery] ${this.file.name}: skipping already executed step ` +
|
|
270
|
+
`${op}/${recovery.done} ${this.current}`
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await State.step(this.pdriver, op, internals);
|
|
276
|
+
|
|
277
|
+
let entry = recovery && op <= recovery.learned
|
|
278
|
+
? mod.s.find(e => e.n === op)
|
|
279
|
+
: null;
|
|
280
|
+
|
|
281
|
+
if (entry) {
|
|
282
|
+
log.info(
|
|
283
|
+
`[recovery] ${this.file.name}: continuing interrupted step ${op} ` +
|
|
284
|
+
this.current
|
|
285
|
+
);
|
|
286
|
+
} else {
|
|
287
|
+
mod.s = mod.s.filter(e => e.n !== op);
|
|
288
|
+
entry = record(op);
|
|
289
|
+
entry.n = op;
|
|
290
|
+
mod.s.push(entry);
|
|
291
|
+
await State.update(this.pdriver, this.file, mod, internals);
|
|
292
|
+
await State.learned(this.pdriver, op, internals);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await execute(entry, op);
|
|
296
|
+
|
|
297
|
+
internals.migrationDone = op;
|
|
298
|
+
await State.done(this.pdriver, op, internals);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
save () {
|
|
302
|
+
return State.update(
|
|
303
|
+
this.pdriver,
|
|
304
|
+
this.file,
|
|
305
|
+
this.internals.modSchema,
|
|
306
|
+
this.internals
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* insert(table, rows, [options]), rows in any form of the SQL API insert.
|
|
312
|
+
*/
|
|
313
|
+
insert (table, ...args) {
|
|
314
|
+
const { rows, options } = insertRows(args);
|
|
315
|
+
const irreversible = options.irreversible === true;
|
|
316
|
+
|
|
317
|
+
if (!irreversible) {
|
|
318
|
+
const columns = this.columns(table);
|
|
319
|
+
|
|
320
|
+
if (!columns || !columns[FLAG]) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`insert("${table}") can only be reverted for tables created by v2 ` +
|
|
323
|
+
`migrations, by their ${FLAG} column` +
|
|
324
|
+
(columns ? `, which "${table}" does not have` : '') +
|
|
325
|
+
'. Pass { irreversible: true } to insert the rows without being ' +
|
|
326
|
+
'able to revert it.'
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (typeof this.driver._insertGroups !== 'function') {
|
|
331
|
+
throw new Error(
|
|
332
|
+
'dml migrations need db-migrate-base 2.5.0 or newer, update your ' +
|
|
333
|
+
'driver'
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return this.step(
|
|
339
|
+
'insert',
|
|
340
|
+
[table],
|
|
341
|
+
op =>
|
|
342
|
+
irreversible
|
|
343
|
+
? { t: 3, a: 'insert', c: [table] }
|
|
344
|
+
: { t: 4, a: 'insert', c: [table, `${this.file.name}#${op}`] },
|
|
345
|
+
async entry => {
|
|
346
|
+
if (!rows.length) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (irreversible) {
|
|
351
|
+
return this.driver.insert(table, rows);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const flag = entry.c[1];
|
|
355
|
+
// rows of an interrupted run of this step
|
|
356
|
+
await this.driver.runSql(
|
|
357
|
+
`DELETE FROM ${this.ddl(table)} WHERE ${this.ddl(FLAG)} = ?`,
|
|
358
|
+
[flag]
|
|
359
|
+
);
|
|
360
|
+
await this.driver.insert(
|
|
361
|
+
table,
|
|
362
|
+
rows.map(row => Object.assign({}, row, { [FLAG]: flag }))
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* update(table, set, where, [options]), set the values of the columns in
|
|
370
|
+
* set for the rows matching where.
|
|
371
|
+
*/
|
|
372
|
+
update (table, set, where, options = {}) {
|
|
373
|
+
if (!isObject(set) || !Object.keys(set).length) {
|
|
374
|
+
throw new Error(`update("${table}") needs the values to set`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const columns = Object.keys(set);
|
|
378
|
+
const condition = this.where(where);
|
|
379
|
+
const irreversible = options.irreversible === true;
|
|
380
|
+
const key = irreversible ? null : this.key(table, options, 'update');
|
|
381
|
+
const assign = columns.map(c => `${this.ddl(c)} = ?`).join(', ');
|
|
382
|
+
const values = columns.map(c => toValue(set[c]));
|
|
383
|
+
|
|
384
|
+
if (key) {
|
|
385
|
+
const changed = key.filter(k => columns.indexOf(k) !== -1);
|
|
386
|
+
if (changed.length) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`update("${table}") can not change the key ${changed.join(', ')}, ` +
|
|
389
|
+
'by which it reverts the rows'
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return this.step(
|
|
395
|
+
'update',
|
|
396
|
+
[table, set],
|
|
397
|
+
op =>
|
|
398
|
+
irreversible
|
|
399
|
+
? { t: 3, a: 'update', c: [table] }
|
|
400
|
+
: {
|
|
401
|
+
t: 4,
|
|
402
|
+
a: 'update',
|
|
403
|
+
c: [table, backupName(this.file, op), key, columns]
|
|
404
|
+
},
|
|
405
|
+
async entry => {
|
|
406
|
+
if (irreversible || this.internals.dryRun) {
|
|
407
|
+
return this.driver.runSql(
|
|
408
|
+
`UPDATE ${this.ddl(table)} SET ${assign} WHERE ${condition.sql}`,
|
|
409
|
+
values.concat(condition.params)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
await this.backup(entry, table, key.concat(columns), condition);
|
|
414
|
+
await this.batches(entry, options, rows => {
|
|
415
|
+
const match = this.byKey(rows, key);
|
|
416
|
+
return this.driver.runSql(
|
|
417
|
+
`UPDATE ${this.ddl(table)} SET ${assign} WHERE ${match.sql}`,
|
|
418
|
+
values.concat(match.params)
|
|
419
|
+
);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* delete(table, where, [options]), delete the rows matching where.
|
|
427
|
+
*/
|
|
428
|
+
delete (table, where, options = {}) {
|
|
429
|
+
const condition = this.where(where);
|
|
430
|
+
const irreversible = options.irreversible === true;
|
|
431
|
+
const key = irreversible ? null : this.key(table, options, 'delete');
|
|
432
|
+
|
|
433
|
+
return this.step(
|
|
434
|
+
'delete',
|
|
435
|
+
[table],
|
|
436
|
+
op =>
|
|
437
|
+
irreversible
|
|
438
|
+
? { t: 3, a: 'delete', c: [table] }
|
|
439
|
+
: { t: 4, a: 'delete', c: [table, backupName(this.file, op), key] },
|
|
440
|
+
async entry => {
|
|
441
|
+
if (irreversible || this.internals.dryRun) {
|
|
442
|
+
return this.driver.runSql(
|
|
443
|
+
`DELETE FROM ${this.ddl(table)} WHERE ${condition.sql}`,
|
|
444
|
+
condition.params
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await this.backup(entry, table, null, condition);
|
|
449
|
+
await this.batches(entry, options, rows => {
|
|
450
|
+
const match = this.byKey(rows, key);
|
|
451
|
+
return this.driver.runSql(
|
|
452
|
+
`DELETE FROM ${this.ddl(table)} WHERE ${match.sql}`,
|
|
453
|
+
match.params
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* runSql(sql, [params], { revert } | { irreversible: true }), revert is
|
|
462
|
+
* the SQL reverting it, or [sql, params].
|
|
463
|
+
*/
|
|
464
|
+
runSql (sql, ...args) {
|
|
465
|
+
const params = Array.isArray(args[0]) ? args.shift() : [];
|
|
466
|
+
const options = args[0] || {};
|
|
467
|
+
const irreversible = options.irreversible === true;
|
|
468
|
+
const revert =
|
|
469
|
+
typeof options.revert === 'string' ? [options.revert, []] : options.revert;
|
|
470
|
+
|
|
471
|
+
if (
|
|
472
|
+
!irreversible &&
|
|
473
|
+
(!Array.isArray(revert) || typeof revert[0] !== 'string')
|
|
474
|
+
) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
'runSql in a dml migration needs the SQL reverting it, ' +
|
|
477
|
+
"runSql(sql, { revert: 'sql' }), or { irreversible: true } to run " +
|
|
478
|
+
'it without being able to revert it.'
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return this.step(
|
|
483
|
+
'runSql',
|
|
484
|
+
[sql],
|
|
485
|
+
() =>
|
|
486
|
+
irreversible
|
|
487
|
+
? { t: 3, a: 'runSql', c: [sql] }
|
|
488
|
+
: { t: 4, a: 'runSql', c: [revert[0], revert[1] || []] },
|
|
489
|
+
() => this.driver.runSql(sql, params)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Reading is no step, it does not change anything.
|
|
495
|
+
*/
|
|
496
|
+
all (...args) {
|
|
497
|
+
return this.driver.all(...args);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Copies the rows about to change into the backup table of the step, only
|
|
502
|
+
* once: a backup completed by an interrupted run holds the rows as they
|
|
503
|
+
* were before the step.
|
|
504
|
+
*/
|
|
505
|
+
async backup (entry, table, columns, condition) {
|
|
506
|
+
if (entry.b) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const backup = this.ddl(entry.c[1]);
|
|
511
|
+
const select = columns ? columns.map(c => this.ddl(c)).join(', ') : '*';
|
|
512
|
+
|
|
513
|
+
await this.driver.runSql(`DROP TABLE IF EXISTS ${backup}`);
|
|
514
|
+
await this.driver.runSql(
|
|
515
|
+
`CREATE TABLE ${backup} AS SELECT ${select} FROM ${this.ddl(table)} ` +
|
|
516
|
+
'WHERE 1 = 0'
|
|
517
|
+
);
|
|
518
|
+
await this.driver.runSql(
|
|
519
|
+
`INSERT INTO ${backup} SELECT ${select} FROM ${this.ddl(table)} ` +
|
|
520
|
+
`WHERE ${condition.sql}`,
|
|
521
|
+
condition.params
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
entry.b = 1;
|
|
525
|
+
entry.o = 0;
|
|
526
|
+
await this.save();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Changes the rows of the backup in batches, ordered by their key. The
|
|
531
|
+
* progress is saved after each batch, changing a batch again after an
|
|
532
|
+
* interruption sets the same values.
|
|
533
|
+
*/
|
|
534
|
+
async batches (entry, options, change) {
|
|
535
|
+
const size = Number(options.batch) || BATCH;
|
|
536
|
+
const backup = this.ddl(entry.c[1]);
|
|
537
|
+
const key = entry.c[2].map(k => this.ddl(k)).join(', ');
|
|
538
|
+
|
|
539
|
+
for (;;) {
|
|
540
|
+
const rows = await this.driver.all(
|
|
541
|
+
`SELECT ${key} FROM ${backup} ORDER BY ${key} ` +
|
|
542
|
+
`LIMIT ${size} OFFSET ${entry.o}`
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
if (!rows.length) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
await change(rows);
|
|
550
|
+
entry.o += rows.length;
|
|
551
|
+
log.verbose(`[dml] ${this.file.name}: ${entry.o} rows ${entry.a}d`);
|
|
552
|
+
await this.save();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Reverts the record of one step. Each is safe to run again, so a revert
|
|
559
|
+
* interrupted in between is continued by running it again.
|
|
560
|
+
*/
|
|
561
|
+
const reverts = {
|
|
562
|
+
insert: (d, [table, flag]) =>
|
|
563
|
+
d.driver.runSql(
|
|
564
|
+
`DELETE FROM ${d.ddl(table)} WHERE ${d.ddl(FLAG)} = ?`,
|
|
565
|
+
[flag]
|
|
566
|
+
),
|
|
567
|
+
|
|
568
|
+
runSql: (d, [sql, params]) => d.driver.runSql(sql, params),
|
|
569
|
+
|
|
570
|
+
update: async (d, [table, backup, key, columns]) => {
|
|
571
|
+
let offset = 0;
|
|
572
|
+
const order = key.map(k => d.ddl(k)).join(', ');
|
|
573
|
+
const assign = columns.map(c => `${d.ddl(c)} = ?`).join(', ');
|
|
574
|
+
const match = key.map(k => `${d.ddl(k)} = ?`).join(' AND ');
|
|
575
|
+
|
|
576
|
+
for (;;) {
|
|
577
|
+
const rows = await d.driver.all(
|
|
578
|
+
`SELECT * FROM ${d.ddl(backup)} ORDER BY ${order} ` +
|
|
579
|
+
`LIMIT ${BATCH} OFFSET ${offset}`
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
if (!rows.length) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
await Promise.each(rows, row =>
|
|
587
|
+
d.driver.runSql(
|
|
588
|
+
`UPDATE ${d.ddl(table)} SET ${assign} WHERE ${match}`,
|
|
589
|
+
columns.map(c => row[c]).concat(key.map(k => row[k]))
|
|
590
|
+
)
|
|
591
|
+
);
|
|
592
|
+
offset += rows.length;
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
|
|
596
|
+
delete: async (d, [table, backup, key]) => {
|
|
597
|
+
const [row] = await d.driver.all(`SELECT * FROM ${d.ddl(backup)} LIMIT 1`);
|
|
598
|
+
|
|
599
|
+
if (!row) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const columns = Object.keys(row)
|
|
604
|
+
.map(c => d.ddl(c))
|
|
605
|
+
.join(', ');
|
|
606
|
+
const match = key
|
|
607
|
+
.map(k => `${d.ddl('__dbm_t')}.${d.ddl(k)} = ${d.ddl('__dbm_b')}.${d.ddl(k)}`)
|
|
608
|
+
.join(' AND ');
|
|
609
|
+
|
|
610
|
+
// rows deleted by the step only, those not deleted yet stay as they are
|
|
611
|
+
await d.driver.runSql(
|
|
612
|
+
`INSERT INTO ${d.ddl(table)} (${columns}) SELECT ${columns} FROM ` +
|
|
613
|
+
`${d.ddl(backup)} ${d.ddl('__dbm_b')} WHERE NOT EXISTS (SELECT 1 ` +
|
|
614
|
+
`FROM ${d.ddl(table)} ${d.ddl('__dbm_t')} WHERE ${match})`
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const irreversibleSteps = internals =>
|
|
620
|
+
internals.modSchema.s
|
|
621
|
+
.filter(entry => entry.t === 3)
|
|
622
|
+
.map(entry => `step ${entry.n} ${entry.a}("${entry.c[0]}")`);
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Reverts the recorded steps, the last first. Steps with a backup table
|
|
626
|
+
* restore the rows from it and drop it afterwards.
|
|
627
|
+
*/
|
|
628
|
+
const revert = async (context, file, internals, pdriver) => {
|
|
629
|
+
const d = new Dml(context, file, internals, pdriver, null);
|
|
630
|
+
const mod = internals.modSchema;
|
|
631
|
+
|
|
632
|
+
while (mod.s.length) {
|
|
633
|
+
const entry = mod.s[mod.s.length - 1];
|
|
634
|
+
const backup = entry.a === 'update' || entry.a === 'delete';
|
|
635
|
+
|
|
636
|
+
if (entry.t !== 4 || !reverts[entry.a]) {
|
|
637
|
+
throw new Error(`Invalid state record of a dml migration, ${entry.a}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (internals.dryRun && backup) {
|
|
641
|
+
log.info(
|
|
642
|
+
`[dml] would restore the rows of ${entry.a}("${entry.c[0]}") from ` +
|
|
643
|
+
entry.c[1]
|
|
644
|
+
);
|
|
645
|
+
} else {
|
|
646
|
+
// b: 1 the backup is complete, 2 the rows are restored from it
|
|
647
|
+
if (!backup || entry.b === 1) {
|
|
648
|
+
await reverts[entry.a](d, entry.c);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (backup) {
|
|
652
|
+
entry.b = 2;
|
|
653
|
+
await d.save();
|
|
654
|
+
await d.driver.runSql(`DROP TABLE IF EXISTS ${d.ddl(entry.c[1])}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
mod.s.pop();
|
|
659
|
+
await d.save();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await State.deleteState(pdriver, file, internals);
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
const opts = context => ({
|
|
666
|
+
options: context.internals.safeOptions,
|
|
667
|
+
dbm: context.internals.safeOptions.dbmigrate
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
const down = async (context, driver, file, { abort } = {}) => {
|
|
671
|
+
const internals = context.internals;
|
|
672
|
+
const pdriver = context._pdriver;
|
|
673
|
+
|
|
674
|
+
if (abort !== true) {
|
|
675
|
+
await State.startMigration(pdriver, file, internals, { op: 'down' });
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const irreversible = irreversibleSteps(internals);
|
|
679
|
+
if (irreversible.length) {
|
|
680
|
+
const err = new Error(
|
|
681
|
+
`Migration "${file.name}" can not be reverted, it ran ` +
|
|
682
|
+
`${irreversible.join(', ')} with { irreversible: true }.`
|
|
683
|
+
);
|
|
684
|
+
err.irreversible = true;
|
|
685
|
+
|
|
686
|
+
if (abort !== true) {
|
|
687
|
+
await State.endMigration(pdriver, file, internals);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
throw err;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
await revert(context, file, internals, pdriver);
|
|
694
|
+
await State.endMigration(pdriver, file, internals);
|
|
695
|
+
return Promise.promisify(context.deleteMigrationRecord.bind(context))(file);
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const up = async (context, driver, file) => {
|
|
699
|
+
const internals = context.internals;
|
|
700
|
+
const pdriver = context._pdriver;
|
|
701
|
+
let recovery = null;
|
|
702
|
+
|
|
703
|
+
const interrupted = await State.startMigration(pdriver, file, internals, {
|
|
704
|
+
recover: true
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
if (interrupted) {
|
|
708
|
+
const mode = recoveryMode(file, interrupted);
|
|
709
|
+
log.warn(
|
|
710
|
+
`[recovery] ${file.name}: the previous run was interrupted at step ` +
|
|
711
|
+
`${interrupted.step}, ${interrupted.done} steps were executed, ` +
|
|
712
|
+
`recovering by ${mode}`
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
if (mode === 'rollback') {
|
|
716
|
+
await State.rollingBack(pdriver, internals);
|
|
717
|
+
await revert(context, file, internals, pdriver);
|
|
718
|
+
internals.modSchema = { i: {}, c: {}, f: {}, s: [] };
|
|
719
|
+
await State.endMigration(pdriver, file, internals);
|
|
720
|
+
await State.startMigration(pdriver, file, internals);
|
|
721
|
+
} else {
|
|
722
|
+
recovery = interrupted;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
internals.migrationDone = recovery ? recovery.done : 0;
|
|
727
|
+
const db = new Dml(context, file, internals, pdriver, recovery);
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
await file.get().migrate(db, opts(context));
|
|
731
|
+
} catch (err) {
|
|
732
|
+
describeFailure(err, db, internals);
|
|
733
|
+
|
|
734
|
+
if (irreversibleSteps(internals).length) {
|
|
735
|
+
log.error(
|
|
736
|
+
`Migration "${file.name}" failed and can not be rolled back, it ` +
|
|
737
|
+
'ran a step with { irreversible: true }. The steps executed ' +
|
|
738
|
+
'stay, the next run continues after them.'
|
|
739
|
+
);
|
|
740
|
+
throw err;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// the record of the failed step reverts what it changed, if anything
|
|
744
|
+
log.error(
|
|
745
|
+
`Migration "${file.name}" failed, rolling back: ${
|
|
746
|
+
(err && err.message) || err
|
|
747
|
+
}`
|
|
748
|
+
);
|
|
749
|
+
await State.rollingBack(pdriver, internals);
|
|
750
|
+
await down(context, driver, file, { abort: true });
|
|
751
|
+
throw err;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
await Promise.promisify(context.writeMigrationRecord.bind(context))(file);
|
|
755
|
+
return State.endMigration(pdriver, file, internals);
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
module.exports = { isDml, checkType, up, down, Dml, FLAG, insertRows };
|