Last active
August 29, 2015 14:25
-
-
Save brettjonesdev/549005e28cfb8679c7c0 to your computer and use it in GitHub Desktop.
SequentialSaveModel
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Pass a Model class to modify. Will prevent concurrent ajax calls on model instances, instead queueing each call to `save`/`destroy`/`fetch` and running them sequentially. | |
// save/fetch/destroy still return jquery deferred promises so usage shoud not change. | |
function sequentialSyncModel(Model) { | |
function getWrappedMethod(method) { | |
return function() { | |
if (!this._steps) { | |
this._steps = []; | |
} | |
var currentArgs = arguments.length ? Array.prototype.slice.call(arguments, 0) : []; | |
var deferred = new $.Deferred(); | |
var doMethod = _.bind(function () { | |
this._promise = method.apply(this, currentArgs); | |
this._promise.then(function () { | |
deferred.resolve.call(_.toArray(arguments)) | |
}).fail(function () { | |
deferred.reject.call(_.toArray(arguments)) | |
}); | |
if (this._abortingSync) { | |
this._promise.abort(); | |
} | |
return this._promise; | |
}, this); | |
this._steps.push(doMethod); | |
//execute first call immediately | |
if (this._steps.length === 1) { | |
this._executeNextStep(); | |
} | |
return deferred.promise(); | |
} | |
} | |
Model.prototype.save = getWrappedMethod(Model.prototype.save); | |
Model.prototype.destroy = getWrappedMethod(Model.prototype.destroy); | |
Model.prototype.fetch = getWrappedMethod(Model.prototype.fetch); | |
Model.prototype._executeNextStep = function() { | |
if ( this._steps.length ) { | |
var step = this._steps[0]; | |
//always go to next step whether fail or success | |
step().always(_.bind(this._onStepComplete, this)); | |
} else { | |
if ( this._abortingSync ) { | |
//once all the current steps have been executed, consider aborting complete | |
delete this._abortingSync; | |
} | |
delete this._promise; | |
} | |
}; | |
Model.prototype._onStepComplete = function() { | |
this._steps.splice(0, 1); | |
this._executeNextStep(); | |
}; | |
//Provide a way to abort any queued up sync operations, since call `.abort()` on a `model.save()` will not abort future, queued up sync steps. | |
//This will abort all of those as well (though the Ajax calls will still be made, they just will not have their `.then()` callbacks fire) | |
Model.prototype.abortSync = function() { | |
if ( this._promise ) { | |
this._abortingSync = true; | |
this._promise.abort(); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
describe("Sequential Sync Model tests", function() { | |
var server; | |
beforeAll(function() { | |
server = sinon.fakeServer.create(); | |
server.respondWith("GET", "/success", '{"id": 123, "name": "My Model"}'); | |
server.respondWith("PUT", "/success", '{"id": 123, "name": "My Model"}'); | |
server.respondWith("POST", "/success", '{"id": 123, "name": "My Model"}'); | |
server.respondWith("DELETE","/success", '{"growl": "My Model deleted"}'); | |
server.respondWith("POST", "/failUrl", [500, { "Content-Type": "application/json" }, '{"status":"FAIL"}']); | |
server.respondWith("PUT", "/failUrl", [500, { "Content-Type": "application/json" }, '{"status":"FAIL"}']); | |
}); | |
afterAll(function() { | |
server.restore(); | |
}); | |
var Model = Backbone.Model.extend({url: "/success"}); | |
sequentialSyncModel(Model); | |
it ("Model modified with sequentialSyncModel prevents concurrent sync calls, runs them sequentially", function() { | |
var model = new Model(); | |
spyOn(Backbone, "sync").and.callThrough(); | |
var callback1 = jasmine.createSpy("callback1"); | |
model.save({body: "a"}, {wait: true}).then(callback1); | |
var callback2 = jasmine.createSpy("callback2"); | |
model.save({body: "ab"}, {wait: true}).then(callback2); | |
var callback3 = jasmine.createSpy("callback3"); | |
model.save({body: "abc"}, {wait: true}).then(callback3); | |
var fetchCallback = jasmine.createSpy("fetchCallback"); | |
model.fetch().then(fetchCallback); | |
var destroyCallback = jasmine.createSpy("destroyCallback"); | |
//Confirm that destroy gets queued up along with PUTs and POSTs | |
model.set('id', 12345); //needs an id for destroy to call server | |
model.destroy({wait: true}).then(destroyCallback); | |
expect(Backbone.sync.calls.count()).toEqual(1); | |
expect(Backbone.sync.calls.mostRecent().args[0]).toEqual('create'); | |
server.respond(); | |
expect(callback1).toHaveBeenCalled(); | |
expect(callback2).not.toHaveBeenCalled(); | |
expect(Backbone.sync.calls.count()).toEqual(2); | |
expect(Backbone.sync.calls.mostRecent().args[0]).toEqual('update'); | |
server.respond(); | |
expect(callback2).toHaveBeenCalled(); | |
expect(callback3).not.toHaveBeenCalled(); | |
expect(Backbone.sync.calls.count()).toEqual(3); | |
expect(Backbone.sync.calls.mostRecent().args[0]).toEqual('update'); | |
server.respond(); | |
expect(callback3).toHaveBeenCalled(); | |
expect(fetchCallback).not.toHaveBeenCalled(); | |
expect(Backbone.sync.calls.count()).toEqual(4); | |
expect(Backbone.sync.calls.mostRecent().args[0]).toEqual('read'); | |
//the fetch | |
server.respond(); | |
expect(fetchCallback).toHaveBeenCalled(); | |
expect(destroyCallback).not.toHaveBeenCalled(); | |
expect(Backbone.sync.calls.count()).toEqual(5); | |
expect(Backbone.sync.calls.mostRecent().args[0]).toEqual('delete'); | |
//the destroy | |
server.respond(); | |
expect(destroyCallback).toHaveBeenCalled(); | |
}); | |
// Models which have been modified with `sequentialSyncModel` should queue up sync steps, | |
// always executing the next step regardless of success or failure. | |
// They should also be able to be aborted with model.abortSync(). This method should abort the current sync promise, | |
// and move on to the next step, executing each sync but *immediately* aborting, causing the data to _still_ go to the server, | |
// but any .then() callbacks to never fire. The goal is to prevent Views which have one of these Models from having a `.then` | |
// fire _after_ the View has been closed. Now we can simply call `this.model.abortSync()` in the View's `onClose`, and be confident that | |
// any sync calls we previously had made will execute, but their callbacks will not (although .fail() will still be called, but with | |
// a message param of "abort", so we can easily ignore those | |
it ("Model modified with sequentialSyncModel abortSync() works as expected with queued syncs", function() { | |
var model = new Model(); | |
spyOn(Backbone, "sync").and.callThrough(); | |
var success1 = jasmine.createSpy("success1"); | |
var fail1 = jasmine.createSpy("fail1"); | |
var always1 = jasmine.createSpy("always1"); | |
model.save({body: "a"}, {wait: true}).then(success1).fail(fail1).always(always1); | |
var success2 = jasmine.createSpy("success2"); | |
var fail2 = jasmine.createSpy("fail2"); | |
var always2 = jasmine.createSpy("always2"); | |
model.save({body: "ab"}, {wait: true}).then(success2).fail(fail2).always(always2); | |
var success3 = jasmine.createSpy("success3"); | |
var fail3 = jasmine.createSpy("fail3"); | |
var always3 = jasmine.createSpy("always3"); | |
model.save({body: "abc"}, {wait: true}).then(success3).fail(fail3).always(always3); | |
expect(Backbone.sync.calls.count()).toEqual(1); | |
server.respond(); | |
expect(success1).toHaveBeenCalled(); | |
expect(always1).toHaveBeenCalled(); | |
expect(fail1).not.toHaveBeenCalled(); | |
expect(Backbone.sync.calls.count()).toEqual(2); | |
//Call abort after the first response. Should abort the second request and result in 3rd being _immediately_ aborted | |
model.abortSync(); | |
server.respond(); | |
expect(success2).not.toHaveBeenCalled(); | |
expect(always2).toHaveBeenCalled(); | |
expect(fail2).toHaveBeenCalled(); | |
expect(fail2.calls.mostRecent().object[1]).toEqual('abort'); | |
expect(Backbone.sync.calls.count()).toEqual(3); | |
server.respond(); | |
expect(success3).not.toHaveBeenCalled(); | |
expect(always3).toHaveBeenCalled(); | |
expect(fail3).toHaveBeenCalled(); | |
expect(fail3.calls.mostRecent().object[1]).toEqual('abort'); | |
//Test that a 500 HTTP failed request calls fail, and does _not_ prevent the next-queued request from succeeding | |
var success4 = jasmine.createSpy("success4"); | |
var fail4 = jasmine.createSpy("fail4"); | |
var always4 = jasmine.createSpy("always4"); | |
model.save({body: 123}, {url: "/failUrl", wait: true}).then(success4).fail(fail4).always(always4); | |
var success5 = jasmine.createSpy("success5"); | |
var fail5 = jasmine.createSpy("fail5"); | |
var always5 = jasmine.createSpy("always5"); | |
model.save({body: "abc"}, {wait: true}).then(success5).fail(fail5).always(always5); | |
//The failure | |
server.respond(); | |
expect(success4).not.toHaveBeenCalled(); | |
expect(always4).toHaveBeenCalled(); | |
expect(fail4).toHaveBeenCalled(); | |
var failResponse = fail4.calls.mostRecent().object[0].responseText; | |
expect($.parseJSON(failResponse).status).toEqual('FAIL'); | |
//The success | |
server.respond(); | |
expect(success5).toHaveBeenCalled(); | |
expect(always5).toHaveBeenCalled(); | |
expect(fail5).not.toHaveBeenCalled(); | |
}); | |
}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment