Merge pull request #193 from matthewdickinson/foreign_key_migration

Foreign key migration
This commit is contained in:
Sadman Sakib Hasan 2017-02-20 16:19:00 -08:00 committed by GitHub
commit 692c07bd51
2 changed files with 436 additions and 26 deletions

View File

@ -21,6 +21,7 @@ function mixinMigration(MySQL, mysql) {
*/
MySQL.prototype.autoupdate = function(models, cb) {
var self = this;
var foreignKeyStatements = [];
if ((!cb) && ('function' === typeof models)) {
cb = models;
@ -41,15 +42,55 @@ function mixinMigration(MySQL, mysql) {
}
var table = self.tableEscaped(model);
self.execute('SHOW FIELDS FROM ' + table, function(err, fields) {
if (err) console.log('Failed to discover "' + table + '" fields', err);
self.execute('SHOW INDEXES FROM ' + table, function(err, indexes) {
if (!err && fields && fields.length) {
self.alterTable(model, fields, indexes, done);
} else {
self.createTable(model, done);
}
if (err) console.log('Failed to discover "' + table + '" indexes', err);
self.discoverForeignKeys(self.table(model), {}, function(err, foreignKeys) {
if (err) console.log('Failed to discover "' + table + '" foreign keys', err);
if (!err && fields && fields.length) {
//if we already have a definition, update this table
self.alterTable(model, fields, indexes, foreignKeys, function(err, changed, res) {
//check to see if there were any new foreign keys for this table.
//If so, we'll create them once all tables have been updated
if (!err && res && res.newFks && res.newFks.length) {
foreignKeyStatements.push(res.newFks);
}
done(err);
});
} else {
//if there is not yet a definition, create this table
var res = self.createTable(model, function(err) {
if (!err) {
//get a list of the alter statements needed to add the defined foreign keys
var newFks = self.getForeignKeySQL(model, foreignKeys);
//check to see if there were any new foreign keys for this table.
//If so, we'll create them once all tables have been updated
if (newFks && newFks.length) {
foreignKeyStatements.push(self.getAlterStatement(model, newFks));
}
}
done(err);
});
}
});
});
});
}, cb);
}, function(err) {
if (err) return cb(err);
//add any new foreign keys
async.each(foreignKeyStatements, function(addFkStmt, execDone) {
self.execute(addFkStmt, execDone);
}, function(err) {
cb(err);
});
});
};
/*!
@ -92,26 +133,62 @@ function mixinMigration(MySQL, mysql) {
async.each(models, function(model, done) {
var table = self.tableEscaped(model);
self.execute('SHOW FIELDS FROM ' + table, function(err, fields) {
if (err) console.log('Failed to discover "' + table + '" fields', err);
self.execute('SHOW INDEXES FROM ' + table, function(err, indexes) {
self.alterTable(model, fields, indexes, function(err, needAlter) {
if (err) {
return done(err);
} else {
ok = ok || needAlter;
done(err);
}
}, true);
if (err) console.log('Failed to discover "' + table + '" indexes', err);
self.discoverForeignKeys(self.table(model), {}, function(err, foreignKeys) {
if (err) console.log('Failed to discover "' + table + '" foreign keys', err);
self.alterTable(model, fields, indexes, foreignKeys, function(err, needAlter) {
if (err) {
return done(err);
} else {
ok = ok || needAlter;
done(err);
}
}, true);
});
});
});
}, function(err) {
if (err) {
return err;
}
cb(null, !ok);
cb(err, !ok);
});
};
MySQL.prototype.alterTable = function(model, actualFields, actualIndexes, done, checkOnly) {
MySQL.prototype.getForeignKeySQL = function(model, actualFks) {
var self = this;
var m = this.getModelDefinition(model);
var addFksSql = [];
var newFks = m.settings.foreignKeys || {};
var newFkNames = Object.keys(newFks).filter(function(name) {
return !!m.settings.foreignKeys[name];
});
//add new foreign keys
if (newFkNames.length) {
//narrow down our key names to only those that don't already exist
var oldKeyNames = actualFks.map(function(oldKey) { return oldKey.fkName; });
newFkNames.filter(function(key) { return !~oldKeyNames.indexOf(key); })
.forEach(function(key) {
var constraint = self.buildForeignKeyDefinition(model, key);
if (constraint) {
addFksSql.push('ADD ' + constraint);
}
});
}
return addFksSql;
};
MySQL.prototype.alterTable = function(model, actualFields, actualIndexes, actualFks, done, checkOnly) {
//if this is using an old signature, then grab the correct callback and check boolean
if ('function' == typeof actualFks && typeof done !== 'function') {
checkOnly = done || false;
done = actualFks;
}
var self = this;
var m = this.getModelDefinition(model);
var propNames = Object.keys(m.properties).filter(function(name) {
@ -121,6 +198,9 @@ function mixinMigration(MySQL, mysql) {
var indexNames = Object.keys(indexes).filter(function(name) {
return !!m.settings.indexes[name];
});
//add new foreign keys
var correctFks = m.settings.foreignKeys || {};
var sql = [];
var ai = {};
@ -159,6 +239,36 @@ function mixinMigration(MySQL, mysql) {
}
});
//drop foreign keys for removed fields
if (actualFks) {
var removedFks = [];
actualFks.forEach(function(fk) {
var needsToDrop = false;
var newFk = correctFks[fk.fkName];
if (newFk) {
var fkCol = expectedColName(newFk.foreignKey);
var fkEntity = self.getModelDefinition(newFk.entity);
var fkRefKey = expectedColNameForModel(newFk.entityKey, fkEntity);
var fkRefTable = newFk.entity.name; //TODO check for mysql name
needsToDrop = fkCol != fk.fkColumnName ||
fkRefKey != fk.pkColumnName ||
fkRefTable != fk.pkTableName;
} else {
needsToDrop = true;
}
if (needsToDrop) {
sql.push('DROP FOREIGN KEY ' + fk.fkName);
removedFks.push(fk); //keep track that we removed these
}
});
//update out list of existing keys by removing dropped keys
actualFks = actualFks.filter(function(k) {
return removedFks.indexOf(k) == -1;
});
}
// drop columns
if (actualFields) {
actualFields.forEach(function(f) {
@ -177,6 +287,8 @@ function mixinMigration(MySQL, mysql) {
aiNames.forEach(function(indexName) {
if (indexName === 'PRIMARY' ||
(m.properties[indexName] && self.id(model, indexName))) return;
if (Object.keys(actualFks).indexOf(indexName) > -1) return; //this index is from an FK
if (indexNames.indexOf(indexName) === -1 && !m.properties[indexName] ||
m.properties[indexName] && !m.properties[indexName].index) {
sql.push('DROP INDEX ' + self.client.escapeId(indexName));
@ -293,13 +405,35 @@ function mixinMigration(MySQL, mysql) {
}
});
if (sql.length) {
var query = 'ALTER TABLE ' + self.tableEscaped(model) + ' ' +
sql.join(',\n');
if (checkOnly) {
done(null, true, {statements: sql, query: query});
//since we're passing the actualFks list to this,
//this code has be called after foreign keys have been dropped
//(in case we're replacing FKs)
var addFksSql = this.getForeignKeySQL(model, actualFks);
//determine if there are column, index, or foreign keys changes (all require update)
if (sql.length || addFksSql.length) {
//get the required alter statements
var alterStmt = self.getAlterStatement(model, sql);
var newFksStatement = self.getAlterStatement(model, addFksSql);
var stmtList = [alterStmt, newFksStatement].filter(function(s) { return s.length; });
//set up an object to pass back all changes, changes that have been run,
//and foreign key statements that haven't been run
var retValues = {
statements: stmtList,
query: stmtList.join(';'),
newFks: newFksStatement,
};
//if we're running in read only mode OR if the only changes are foreign keys additions,
//then just return the object directly
if (checkOnly || !alterStmt.length) {
done(null, true, retValues);
} else {
this.execute(query, done);
//if there are changes in the alter statement, then execute them and return the object
self.execute(alterStmt, function(err) {
done(err, true, retValues);
});
}
} else {
done();
@ -336,7 +470,11 @@ function mixinMigration(MySQL, mysql) {
}
function expectedColName(propName) {
var mysql = m.properties[propName].mysql;
return expectedColNameForModel(propName, m);
}
function expectedColNameForModel(propName, modelToCheck) {
var mysql = modelToCheck.properties[propName].mysql;
if (typeof mysql === 'undefined') {
return propName;
}
@ -348,6 +486,31 @@ function mixinMigration(MySQL, mysql) {
}
};
MySQL.prototype.getAlterStatement = function(model, statements) {
return statements.length ?
'ALTER TABLE ' + this.tableEscaped(model) + ' ' + statements.join(',\n') :
'';
};
MySQL.prototype.buildForeignKeyDefinition = function(model, keyName) {
var definition = this.getModelDefinition(model);
var fk = definition.settings.foreignKeys[keyName];
if (fk) {
//get the definition of the referenced object
var fkEntityName = (typeof fk.entity === 'object') ? fk.entity.name : fk.entity;
//verify that the other model in the same DB
if (this._models[fkEntityName]) {
return ' CONSTRAINT ' + this.client.escapeId(fk.name) +
' FOREIGN KEY (' + fk.foreignKey + ')' +
' REFERENCES ' + this.tableEscaped(fkEntityName) +
'(' + this.client.escapeId(fk.entityKey) + ')';
}
}
return '';
};
MySQL.prototype.buildColumnDefinitions =
MySQL.prototype.propertiesSQL = function(model) {
var self = this;
@ -385,6 +548,7 @@ function mixinMigration(MySQL, mysql) {
indexes.forEach(function(i) {
sql.push(i);
});
return sql.join(',\n ');
};

View File

@ -228,6 +228,252 @@ describe('MySQL connector', function() {
});
});
it('should auto migrate/update foreign keys in tables', function(done) {
var customer2_schema =
{
'name': 'CustomerTest2',
'options': {
'idInjection': false,
'mysql': {
'schema': 'myapp_test',
'table': 'customer_test2',
},
},
'properties': {
'id': {
'type': 'String',
'length': 20,
'id': 1,
},
'name': {
'type': 'String',
'required': false,
'length': 40,
},
'email': {
'type': 'String',
'required': true,
'length': 40,
},
'age': {
'type': 'Number',
'required': false,
},
},
};
var customer3_schema =
{
'name': 'CustomerTest3',
'options': {
'idInjection': false,
'mysql': {
'schema': 'myapp_test',
'table': 'customer_test3',
},
},
'properties': {
'id': {
'type': 'String',
'length': 20,
'id': 1,
},
'name': {
'type': 'String',
'required': false,
'length': 40,
},
'email': {
'type': 'String',
'required': true,
'length': 40,
},
'age': {
'type': 'Number',
'required': false,
},
},
};
var schema_v1 =
{
'name': 'OrderTest',
'options': {
'idInjection': false,
'mysql': {
'schema': 'myapp_test',
'table': 'order_test',
},
'foreignKeys': {
'fk_ordertest_customerId': {
'name': 'fk_ordertest_customerId',
'entity': 'CustomerTest3',
'entityKey': 'id',
'foreignKey': 'customerId',
},
},
},
'properties': {
'id': {
'type': 'String',
'length': 20,
'id': 1,
},
'customerId': {
'type': 'String',
'length': 20,
'id': 1,
},
'description': {
'type': 'String',
'required': false,
'length': 40,
},
},
};
var schema_v2 =
{
'name': 'OrderTest',
'options': {
'idInjection': false,
'mysql': {
'schema': 'myapp_test',
'table': 'order_test',
},
'foreignKeys': {
'fk_ordertest_customerId': {
'name': 'fk_ordertest_customerId',
'entity': 'CustomerTest2',
'entityKey': 'id',
'foreignKey': 'customerId',
},
},
},
'properties': {
'id': {
'type': 'String',
'length': 20,
'id': 1,
},
'customerId': {
'type': 'String',
'length': 20,
'id': 1,
},
'description': {
'type': 'String',
'required': false,
'length': 40,
},
},
};
var schema_v3 =
{
'name': 'OrderTest',
'options': {
'idInjection': false,
'mysql': {
'schema': 'myapp_test',
'table': 'order_test',
},
},
'properties': {
'id': {
'type': 'String',
'length': 20,
'id': 1,
},
'customerId': {
'type': 'String',
'length': 20,
'id': 1,
},
'description': {
'type': 'String',
'required': false,
'length': 40,
},
},
};
var foreignKeySelect =
'SELECT COLUMN_NAME,CONSTRAINT_NAME,REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME ' +
'FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE ' +
'WHERE REFERENCED_TABLE_SCHEMA = "myapp_test" ' +
'AND TABLE_NAME = "order_test"';
ds.createModel(customer2_schema.name, customer2_schema.properties, customer2_schema.options);
ds.createModel(customer3_schema.name, customer3_schema.properties, customer3_schema.options);
ds.createModel(schema_v1.name, schema_v1.properties, schema_v1.options);
//do initial update/creation of table
ds.autoupdate(function() {
ds.discoverModelProperties('order_test', function(err, props) {
//validate that we have the correct number of properties
assert.equal(props.length, 3);
//get the foreign keys for this table
ds.connector.execute(foreignKeySelect, function(err, foreignKeys) {
if (err) return done (err);
//validate that the foreign key exists and points to the right column
assert(foreignKeys);
assert(foreignKeys.length.should.be.equal(1));
assert.equal(foreignKeys[0].REFERENCED_TABLE_NAME, 'customer_test3');
assert.equal(foreignKeys[0].COLUMN_NAME, 'customerId');
assert.equal(foreignKeys[0].CONSTRAINT_NAME, 'fk_ordertest_customerId');
assert.equal(foreignKeys[0].REFERENCED_COLUMN_NAME, 'id');
//update our model (move foreign key) and run autoupdate to migrate
ds.createModel(schema_v2.name, schema_v2.properties, schema_v2.options);
ds.autoupdate(function(err, result) {
if (err) return done (err);
//get and validate the properties on this model
ds.discoverModelProperties('order_test', function(err, props) {
if (err) return done (err);
assert.equal(props.length, 3);
//get the foreign keys that exist after the migration
ds.connector.execute(foreignKeySelect, function(err, updatedForeignKeys) {
if (err) return done (err);
//validate that the foreign keys was moved to the new column
assert(updatedForeignKeys);
assert(updatedForeignKeys.length.should.be.equal(1));
assert.equal(updatedForeignKeys[0].REFERENCED_TABLE_NAME, 'customer_test2');
assert.equal(updatedForeignKeys[0].COLUMN_NAME, 'customerId');
assert.equal(updatedForeignKeys[0].CONSTRAINT_NAME, 'fk_ordertest_customerId');
assert.equal(updatedForeignKeys[0].REFERENCED_COLUMN_NAME, 'id');
//update model (to drop foreign key) and autoupdate
ds.createModel(schema_v3.name, schema_v3.properties, schema_v3.options);
ds.autoupdate(function(err, result) {
if (err) return done (err);
//validate the properties
ds.discoverModelProperties('order_test', function(err, props) {
if (err) return done (err);
assert.equal(props.length, 3);
//get the foreign keys and validate the foreign key has been dropped
ds.connector.execute(foreignKeySelect, function(err, thirdForeignKeys) {
if (err) return done (err);
assert(thirdForeignKeys);
assert(thirdForeignKeys.length.should.be.equal(0));
done(err, result);
});
});
});
});
});
});
});
});
});
});
function setupAltColNameData() {
var schema = {
name: 'ColRenameTest',