Skip to content

Instantly share code, notes, and snippets.

@brettjonesdev
Last active August 29, 2015 14:25
Show Gist options
  • Save brettjonesdev/549005e28cfb8679c7c0 to your computer and use it in GitHub Desktop.
Save brettjonesdev/549005e28cfb8679c7c0 to your computer and use it in GitHub Desktop.
SequentialSaveModel
// 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();
}
}
}
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