diff --git a/lib/migration.js b/lib/migration.js index b8e9cdc..a39b6c5 100644 --- a/lib/migration.js +++ b/lib/migration.js @@ -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 '); }; diff --git a/test/mysql.autoupdate.test.js b/test/mysql.autoupdate.test.js index 3975fee..98077d2 100644 --- a/test/mysql.autoupdate.test.js +++ b/test/mysql.autoupdate.test.js @@ -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',