Refactor to use an explicit "Patcher" class for state management.

This commit is contained in:
Ryan Kelly 2015-01-30 16:41:58 +11:00
Родитель fd4fd06432
Коммит 08e2dd935b
5 изменённых файлов: 286 добавлений и 228 удалений

208
index.js
Просмотреть файл

@ -15,50 +15,63 @@ var noop = Function.prototype // a No Op function
var ERR_NO_SUCH_TABLE = 1146
// the main export from this package
function patch(options, callback) {
callback = callback || noop
// A stateful Patcher class for interacting with the db.
// This is the main export form this module.
function Patcher(options) {
this.options = clone(options)
// check the required options
if ( !options.dir ) {
return callback(new Error("Option 'dir' is required"))
if ( !this.options.dir ) {
throw new Error("Option 'dir' is required")
}
if ( !('patchLevel' in options ) ) {
return callback(new Error("Option 'patchLevel' is required"))
if ( !('patchLevel' in this.options ) ) {
throw new Error("Option 'patchLevel' is required")
}
if ( !this.options.mysql || !this.options.mysql.createConnection ) {
throw new Error("Option 'mysql' must be a mysql module object")
}
// set some defaults
options.metaTable = options.metaTable || 'metadata'
options.reversePatchAllowed = options.reversePatchAllowed || false
options.patchKey = options.patchKey || 'patch'
options.createDatabase = options.createDatabase || false
this.options.metaTable = this.options.metaTable || 'metadata'
this.options.reversePatchAllowed = this.options.reversePatchAllowed || false
this.options.patchKey = this.options.patchKey || 'patch'
this.options.createDatabase = this.options.createDatabase || false
// set this on the connection since we can have multiple statements in every patch
options.multipleStatements = true
// set this on the connection since we can have multiple statements
// in every patch
this.options.multipleStatements = true
// ToDo: fill in once other supporting functions are complete
// some stub properties, mostly for documentation purposes.
this.connection = null
this.metaTableExists = undefined
this.currentPatchLevel = undefined
this.patches = {}
this.patchesToApply = []
var context = {
options : options,
}
}
Patcher.prototype.patch = function patch(callback) {
async.series(
[
createConnection.bind(context),
createDatabase.bind(context),
changeUser.bind(context),
checkDbMetadataExists.bind(context),
readDbPatchLevel.bind(context),
readPatchFiles.bind(context),
checkAllPatchesAvailable.bind(context),
applyPatches.bind(context),
this.createConnection.bind(this),
this.createDatabase.bind(this),
this.changeUser.bind(this),
this.checkDbMetadataExists.bind(this),
this.readDbPatchLevel.bind(this),
this.readPatchFiles.bind(this),
this.checkAllPatchesAvailable.bind(this),
this.applyPatches.bind(this),
],
function(err) {
(function(err) {
// firstly check for errors
if (err) {
// close the connection if we have one open
if ( context.connection ) {
context.connection.end(function(err) {
if ( this.connection ) {
this.connection.end(function(err) {
// ignore any errors here since we already have one
})
}
@ -66,15 +79,15 @@ function patch(options, callback) {
}
// all ok, so just close the connection normally
context.connection.end(function(err2) {
this.connection.end(function(err2) {
// ignore this error if there is one, callback with the original error
callback(err2)
})
}
}).bind(this)
)
}
function createConnection(callback) {
Patcher.prototype.createConnection = function createConnection(callback) {
// when creating the database, we need to connect without a database name
var opts = clone(this.options)
delete opts.database
@ -88,7 +101,7 @@ function createConnection(callback) {
})
}
function createDatabase(callback) {
Patcher.prototype.createDatabase = function createDatabase(callback) {
if ( this.options.createDatabase ) {
this.connection.query(
'CREATE DATABASE IF NOT EXISTS ' + this.options.database + ' CHARACTER SET utf8 COLLATE utf8_unicode_ci',
@ -100,7 +113,7 @@ function createDatabase(callback) {
}
}
function changeUser(callback) {
Patcher.prototype.changeUser = function changeUser(callback) {
this.connection.changeUser(
{
user : this.options.user,
@ -111,68 +124,64 @@ function changeUser(callback) {
)
}
function checkDbMetadataExists(callback) {
var ctx = this
Patcher.prototype.checkDbMetadataExists = function checkDbMetadataExists(callback) {
var query = "SELECT COUNT(*) AS count FROM information_schema.TABLES WHERE table_schema = ? AND table_name = ?"
this.connection.query(
query,
[ ctx.options.database, ctx.options.metaTable ],
function (err, result) {
[ this.options.database, this.options.metaTable ],
(function (err, result) {
if (err) { return callback(err) }
ctx.metaTableExists = result[0].count === 0 ? false : true
this.metaTableExists = result[0].count === 0 ? false : true
callback()
}
}).bind(this)
)
}
function readDbPatchLevel(callback) {
var ctx = this
if ( ctx.metaTableExists === false ) {
Patcher.prototype.readDbPatchLevel = function readDbPatchLevel(callback) {
if ( this.metaTableExists === false ) {
// the table doesn't exist, so start at patch level 0
ctx.currentPatchLevel = 0
this.currentPatchLevel = 0
process.nextTick(callback)
return
}
// find out what patch level the database is currently at
var query = "SELECT value FROM " + ctx.options.metaTable + " WHERE name = ?"
ctx.connection.query(
var query = "SELECT value FROM " + this.options.metaTable + " WHERE name = ?"
this.connection.query(
query,
[ ctx.options.patchKey ],
function(err, result) {
[ this.options.patchKey ],
(function(err, result) {
if (err) { return callback(err) }
if ( result.length === 0 ) {
// nothing in the table yet
ctx.currentPatchLevel = 0
this.currentPatchLevel = 0
}
else {
// convert the patch level from a string to a number
ctx.currentPatchLevel = +result[0].value
this.currentPatchLevel = +result[0].value
}
callback()
}
}).bind(this)
)
}
function readPatchFiles(callback) {
var ctx = this
Patcher.prototype.readPatchFiles = function readPatchFiles(callback) {
ctx.patches = {}
this.patches = {}
fs.readdir(ctx.options.dir, function(err, files) {
fs.readdir(this.options.dir, (function(err, files) {
if (err) return callback(err)
files = files.map(function(filename) {
return path.join(ctx.options.dir, filename)
})
files = files.map((function(filename) {
return path.join(this.options.dir, filename)
}).bind(this))
async.eachLimit(
files,
10,
function(filename, done) {
(function(filename, done) {
var m = filename.match(/-(\d+)-(\d+)\.sql$/)
if ( !m ) {
return done(new Error('Unknown file format: ' + filename))
@ -180,42 +189,41 @@ function readPatchFiles(callback) {
var from = parseInt(m[1], 10)
var to = parseInt(m[2], 10)
ctx.patches[from] = ctx.patches[from] || {}
this.patches[from] = this.patches[from] || {}
fs.readFile(filename, { encoding : 'utf8' }, function(err, data) {
fs.readFile(filename, { encoding : 'utf8' }, (function(err, data) {
if (err) return done(err)
ctx.patches[from][to] = data
this.patches[from][to] = data
done()
})
},
}).bind(this))
}).bind(this),
function(err) {
if (err) return callback(err)
callback()
}
)
})
}).bind(this))
}
function checkAllPatchesAvailable(callback) {
var ctx = this
Patcher.prototype.checkAllPatchesAvailable = function checkAllPatchesAvailable(callback) {
ctx.patchesToApply = []
this.patchesToApply = []
// if we don't need any patches
if ( ctx.options.patchLevel === ctx.currentPatchLevel ) {
if ( this.options.patchLevel === this.currentPatchLevel ) {
process.nextTick(callback)
return
}
// First, loop through all the patches we need to apply to make sure they exist.
var direction = ctx.currentPatchLevel < ctx.options.patchLevel ? 1 : -1
var currentPatchLevel = ctx.currentPatchLevel
var direction = this.currentPatchLevel < this.options.patchLevel ? 1 : -1
var currentPatchLevel = this.currentPatchLevel
var nextPatchLevel
while ( currentPatchLevel !== ctx.options.patchLevel ) {
while ( currentPatchLevel !== this.options.patchLevel ) {
nextPatchLevel = currentPatchLevel + direction
// check that this patch exists
if ( !ctx.patches[currentPatchLevel] || !ctx.patches[currentPatchLevel][nextPatchLevel] ) {
if ( !this.patches[currentPatchLevel] || !this.patches[currentPatchLevel][nextPatchLevel] ) {
process.nextTick(function() {
callback(new Error('Patch from level ' + currentPatchLevel + ' to ' + nextPatchLevel + ' does not exist'))
})
@ -223,8 +231,8 @@ function checkAllPatchesAvailable(callback) {
}
// add this patch onto the patchesToApply
ctx.patchesToApply.push({
sql : ctx.patches[currentPatchLevel][nextPatchLevel],
this.patchesToApply.push({
sql : this.patches[currentPatchLevel][nextPatchLevel],
from : currentPatchLevel,
to : nextPatchLevel,
})
@ -234,21 +242,19 @@ function checkAllPatchesAvailable(callback) {
callback()
}
function applyPatches(callback) {
var ctx = this
Patcher.prototype.applyPatches = function applyPatches(callback) {
async.eachSeries(
ctx.patchesToApply,
function(patch, donePatch) {
this.patchesToApply,
(function(patch, donePatch) {
// emit : 'Updating DB for patch ' + patch.from + ' to ' + patch.to
ctx.connection.query(patch.sql, function(err, info) {
this.connection.query(patch.sql, (function(err, info) {
if (err) return donePatch(err)
// check that the database is now at the (intermediate) patch level
var query = "SELECT value FROM " + ctx.options.metaTable + " WHERE name = ?"
ctx.connection.query(
var query = "SELECT value FROM " + this.options.metaTable + " WHERE name = ?"
this.connection.query(
query,
[ ctx.options.patchKey ],
[ this.options.patchKey ],
function(err, result) {
if (err) {
// this is not an error if we are wanting to patch to level 0
@ -277,23 +283,33 @@ function applyPatches(callback) {
donePatch()
}
)
})
},
}).bind(this))
}).bind(this),
callback
)
}
function closeConnection(callback) {
Patcher.prototype.closeConnection = function closeConnection(callback) {
this.connection.end(callback)
}
// A much simpler, stateless function for just doing a patch.
Patcher.patch = function patch(options, callback) {
callback = callback || noop
try {
var patcher = new Patcher(options);
} catch (err) {
return callback(err);
}
patcher.patch(callback);
}
// main export
module.exports.patch = patch
// and these for testing purposes
module.exports.createDatabase = createDatabase
module.exports.changeUser = changeUser
module.exports.checkDbMetadataExists = checkDbMetadataExists
module.exports.readDbPatchLevel = readDbPatchLevel
module.exports.readPatchFiles = readPatchFiles
module.exports.checkAllPatchesAvailable = checkAllPatchesAvailable
module.exports.applyPatches = applyPatches
module.exports = Patcher

Просмотреть файл

@ -5,27 +5,32 @@ var path = require('path')
var test = require('tape')
var patcher = require('../')
var mockMySQL = require('./mock-mysql')
test('check that the changeUser is doing the right thing', function(t) {
t.plan(2)
t.plan(5)
var count = 0
var ctx = {
connection : {
var options = {
user : 'user',
password : 'password',
database : 'database',
dir : 'nonexistent',
patchLevel: 0,
mysql : mockMySQL({
changeUser : function(obj, callback) {
t.deepEqual(obj, ctx.options, 'The changeUser was passed user, password and database as expected')
t.equal(obj.user, options.user, 'changeUser was passed user correctly')
t.equal(obj.password, options.password, 'changeUser was passed password correctly')
t.equal(obj.database, options.database, 'changeUser was passed database correctly')
process.nextTick(callback)
},
},
options : {
user : 'user',
password : 'password',
database : 'database',
},
}
})
}
patcher.changeUser.call(ctx, function(err) {
var p = new patcher(options);
p.createConnection(function(err) {
t.ok(!err, 'No error occurred')
t.end()
p.changeUser(function(err) {
t.ok(!err, 'No error occurred')
t.end()
})
})
})

Просмотреть файл

@ -5,49 +5,54 @@ var path = require('path')
var test = require('tape')
var patcher = require('../')
var mockMySQL = require('./mock-mysql')
test('check that createDatabase is being called when asked for', function(t) {
t.plan(2)
t.plan(3)
var count = 0
var ctx = {
connection : {
var p = new patcher({
database : 'database',
createDatabase : true,
dir: 'nonexistent',
patchLevel: 0,
mysql : mockMySQL({
query : function(sql, callback) {
t.equal(sql, 'CREATE DATABASE IF NOT EXISTS database CHARACTER SET utf8 COLLATE utf8_unicode_ci')
callback()
},
},
options : {
database : 'database',
createDatabase : true,
},
}
}
})
})
patcher.createDatabase.call(ctx, function(err) {
p.createConnection(function(err) {
t.ok(!err, 'No error occurred')
t.end()
p.createDatabase(function(err) {
t.ok(!err, 'No error occurred')
t.end()
})
})
})
test('check that createDatabase is not being called when false', function(t) {
t.plan(1)
t.plan(2)
var count = 0
var ctx = {
connection : {
var p = new patcher({
database : 'database',
createDatabase : false,
dir: 'nonexistent',
patchLevel: 0,
mysql : mockMySQL({
query : function(sql, callback) {
t.fail('.query() should not have been called with the create database command')
callback()
},
},
options : {
database : 'database',
createDatabase : false,
},
}
}
})
})
patcher.createDatabase.call(ctx, function(err) {
p.createConnection(function(err) {
t.ok(!err, 'No error occurred')
t.end()
p.createDatabase(function(err) {
t.ok(!err, 'No error occurred')
t.end()
})
})
})

25
test/mock-mysql.js Normal file
Просмотреть файл

@ -0,0 +1,25 @@
/* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/publicdomain/zero/1.0/ */
var xtend = require('xtend')
var defaultConnectionMethods = {
connect: function mockConnect(callback) {
return callback()
},
query: function mockQuery() {
throw 'query() should not have been called'
},
changeUser: function mockChangeUser() {
throw 'changeUser() should not have been called'
}
}
module.exports = function mockMySQL(mockMethods) {
return {
createConnection: function(callback) {
return xtend(defaultConnectionMethods, mockMethods || {});
}
}
}

Просмотреть файл

@ -5,19 +5,20 @@ var path = require('path')
var test = require('tape')
var patcher = require('../')
var mockMySQL = require('./mock-mysql')
test('read patch set (ok)', function (t) {
var ctx = {
options : {
dir : path.join(__dirname, 'patches'),
},
}
// call readPatchFiles() with the above context
patcher.readPatchFiles.call(ctx, function(err) {
var p = new patcher({
dir : path.join(__dirname, 'patches'),
patchLevel : 0,
mysql : mockMySQL()
})
p.readPatchFiles(function(err) {
t.ok(!err, 'No error occurred')
var patches = ctx.patches
var patches = p.patches
// check there are 3 patch levels
var levels = Object.keys(patches).length
@ -39,29 +40,30 @@ test('read patch set (ok)', function (t) {
})
test('check all patches are available (forwards)', function(t) {
var ctx = {
options : {
patchLevel : 2,
},
currentPatchLevel : 0,
patches : {
'0' : {
'1' : '-- 0->1\n',
},
'1' : {
'2' : '-- 1->2\n',
},
var p = new patcher({
patchLevel : 2,
dir : "nonexistent",
mysql : mockMySQL()
})
p.currentPatchLevel = 0
p.patches = {
'0' : {
'1' : '-- 0->1\n',
},
'1' : {
'2' : '-- 1->2\n',
}
}
patcher.checkAllPatchesAvailable.call(ctx, function(err) {
p.checkAllPatchesAvailable(function(err) {
t.ok(!err, 'No error occurred')
var patches = [
{ sql : '-- 0->1\n', from : 0, to : 1, },
{ sql : '-- 1->2\n', from : 1, to : 2, },
]
t.deepEqual(ctx.patchesToApply, patches, 'The patches to be applied')
t.deepEqual(p.patchesToApply, patches, 'The patches to be applied')
t.end()
})
@ -69,29 +71,29 @@ test('check all patches are available (forwards)', function(t) {
})
test('check all patches are available (backwards)', function(t) {
var ctx = {
options : {
patchLevel : 0,
},
currentPatchLevel : 2,
patches : {
'2' : {
'1' : '-- 2->1\n',
},
'1' : {
'0' : '-- 1->0\n',
},
var p = new patcher({
patchLevel : 0,
dir : "nonexistent",
mysql : mockMySQL()
})
p.currentPatchLevel = 2
p.patches = {
'2' : {
'1' : '-- 2->1\n',
},
'1' : {
'0' : '-- 1->0\n',
}
}
patcher.checkAllPatchesAvailable.call(ctx, function(err) {
p.checkAllPatchesAvailable(function(err) {
t.ok(!err, 'No error occurred')
var patches = [
{ sql : '-- 2->1\n', from : 2, to : 1, },
{ sql : '-- 1->0\n', from : 1, to : 0, },
]
t.deepEqual(ctx.patchesToApply, patches, 'The patches to be applied')
t.deepEqual(p.patchesToApply, patches, 'The patches to be applied')
t.end()
})
@ -99,19 +101,19 @@ test('check all patches are available (backwards)', function(t) {
})
test('check all patches are available (fails, no patch #2)', function(t) {
var ctx = {
options : {
patchLevel : 2,
},
currentPatchLevel : 0,
patches : {
'0' : {
'1' : '-- 0->1\n',
},
},
var p = new patcher({
patchLevel : 2,
dir : "nonexistent",
mysql : mockMySQL()
})
p.currentPatchLevel = 0
p.patches = {
'0' : {
'1' : '-- 0->1\n',
}
}
patcher.checkAllPatchesAvailable.call(ctx, function(err) {
p.checkAllPatchesAvailable(function(err) {
t.ok(err, 'An error occurred since patch 2 is missing')
t.equal(err.message, 'Patch from level 1 to 2 does not exist', 'The error message is correct')
@ -121,19 +123,19 @@ test('check all patches are available (fails, no patch #2)', function(t) {
})
test('check all patches are available (fails, no patch #1)', function(t) {
var ctx = {
options : {
patchLevel : 2,
},
currentPatchLevel : 0,
patches : {
'1' : {
'2' : '-- 1->2\n',
},
},
var p = new patcher({
patchLevel : 2,
dir : "nonexistent",
mysql : mockMySQL()
})
p.currentPatchLevel = 0
p.patches = {
'1' : {
'2' : '-- 1->2\n',
}
}
patcher.checkAllPatchesAvailable.call(ctx, function(err) {
p.checkAllPatchesAvailable(function(err) {
t.ok(err, 'An error occurred since patch 1 is missing')
t.equal(err.message, 'Patch from level 0 to 1 does not exist', 'The error message is correct')
@ -144,13 +146,12 @@ test('check all patches are available (fails, no patch #1)', function(t) {
test('checking that these patch files are executed', function(t) {
var count = 0
var ctx = {
options : {
dir : path.join(__dirname, 'end-to-end'),
metaTable : 'metadata',
patchKey : 'schema-patch-level',
},
connection : {
var p = new patcher({
dir : path.join(__dirname, 'end-to-end'),
metaTable : 'metadata',
patchKey : 'schema-patch-level',
patchLevel : 0,
mysql : mockMySQL({
query : function(sql, args, callback) {
if ( typeof callback === 'undefined' ) {
callback = args
@ -160,20 +161,24 @@ test('checking that these patch files are executed', function(t) {
if ( sql.match(/SELECT value FROM metadata WHERE name/) ) {
return callback(null, [])
}
t.equal(sql, ctx.patchesToApply[count].sql, 'SQL is correct')
t.equal(sql, p.patchesToApply[count].sql, 'SQL is correct')
count += 1
callback(null, [])
},
},
}
}
})
})
p.currentPatchLevel = 0
patcher.readPatchFiles.call(ctx, function(err) {
p.createConnection(function(err) {
t.ok(!err, 'No error occurred')
patcher.checkAllPatchesAvailable.call(ctx, function(err) {
p.readPatchFiles(function(err) {
t.ok(!err, 'No error occurred')
patcher.applyPatches.call(ctx, function(err) {
p.checkAllPatchesAvailable(function(err) {
t.ok(!err, 'No error occurred')
t.end()
p.applyPatches(function(err) {
t.ok(!err, 'No error occurred')
t.end()
})
})
})
})
@ -181,28 +186,30 @@ test('checking that these patch files are executed', function(t) {
})
test('checking that an error comes back if a patch is missing', function(t) {
t.plan(3)
t.plan(4)
var count = 0
var ctx = {
options : {
metaTable : 'metadata',
patchKey : 'level',
},
patchesToApply : [
{ sql : '-- 0->1' },
],
connection : {
var p = new patcher({
metaTable : 'metadata',
patchKey : 'level',
patchLevel : 0,
dir : "nonexistent",
mysql : mockMySQL({
query : function(sql, callback) {
t.equal(sql, '-- 0->1', 'The sql is what is expected')
callback(new Error('Something went wrong'))
},
},
}
}
})
})
p.patchesToApply = [
{ sql : '-- 0->1' },
]
patcher.applyPatches.call(ctx, function(err) {
t.ok(err, 'An error occurred')
t.equal(err.message, 'Something went wrong', 'The message is correct')
t.end()
p.createConnection(function(err) {
t.ok(!err, 'No error occurred')
p.applyPatches(function(err) {
t.ok(err, 'An error occurred')
t.equal(err.message, 'Something went wrong', 'The message is correct')
t.end()
})
})
})