Add Change model

This commit is contained in:
Ritchie Martori 2014-01-26 13:20:19 -08:00
parent 7230890aa9
commit 4be3395298
5 changed files with 597 additions and 1 deletions

View File

@ -14,6 +14,7 @@
"lib/models/model.js",
"lib/models/role.js",
"lib/models/user.js",
"lib/models/change.js",
"docs/api-datasource.md",
"docs/api-geopoint.md",
"docs/api-model.md",

View File

@ -292,6 +292,7 @@ loopback.Role = require('./models/role').Role;
loopback.RoleMapping = require('./models/role').RoleMapping;
loopback.ACL = require('./models/acl').ACL;
loopback.Scope = require('./models/acl').Scope;
loopback.Change = require('./models/change');
/*!
* Automatically attach these models to dataSources

325
lib/models/change.js Normal file
View File

@ -0,0 +1,325 @@
/**
* Module Dependencies.
*/
var Model = require('../loopback').Model
, loopback = require('../loopback')
, crypto = require('crypto')
, CJSON = {stringify: require('canonical-json')}
, async = require('async')
, assert = require('assert');
/**
* Properties
*/
var properties = {
id: {type: String, generated: true, id: true},
rev: {type: String},
prev: {type: String},
checkpoint: {type: Number},
modelName: {type: String},
modelId: {type: String}
};
/**
* Options
*/
var options = {
};
/**
* Change list entry.
*
* @property id {String} Hash of the modelName and id
* @property rev {String} the current model revision
* @property prev {String} the previous model revision
* @property checkpoint {Number} the current checkpoint at time of the change
* @property modelName {String} the model name
* @property modelId {String} the model id
*
* @class
* @inherits {Model}
*/
var Change = module.exports = Model.extend('Change', properties, options);
/*!
* Constants
*/
Change.UPDATE = 'update';
Change.CREATE = 'create';
Change.DELETE = 'delete';
Change.UNKNOWN = 'unknown';
/*!
* Setup the extended model.
*/
Change.setup = function() {
var Change = this;
Change.getter.id = function() {
var hasModel = this.modelName && this.modelId;
if(!hasModel) return null;
return Change.idForModel(this.modelName, this.modelId);
}
}
Change.setup();
/**
* Track the recent change of the given modelIds.
*
* @param {String} modelName
* @param {Array} modelIds
* @callback {Function} callback
* @param {Error} err
* @param {Array} changes Changes that were tracked
*/
Change.track = function(modelName, modelIds, callback) {
var tasks = [];
var Change = this;
modelIds.forEach(function(id) {
tasks.push(function(cb) {
Change.findOrCreate(modelName, id, function(err, change) {
if(err) return Change.handleError(err, cb);
change.rectify(cb);
});
});
});
async.parallel(tasks, callback);
}
/**
* Get an identifier for a given model.
*
* @param {String} modelName
* @param {String} modelId
* @return {String}
*/
Change.idForModel = function(modelName, modelId) {
return this.hash([modelName, modelId].join('-'));
}
/**
* Find or create a change for the given model.
*
* @param {String} modelName
* @param {String} modelId
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
* @end
*/
Change.findOrCreate = function(modelName, modelId, callback) {
var id = this.idForModel(modelName, modelId);
var Change = this;
this.findById(id, function(err, change) {
if(err) return callback(err);
if(change) {
callback(null, change);
} else {
var ch = new Change({
id: id,
modelName: modelName,
modelId: modelId
});
ch.save(callback);
}
});
}
/**
* Update (or create) the change with the current revision.
*
* @callback {Function} callback
* @param {Error} err
* @param {Change} change
*/
Change.prototype.rectify = function(cb) {
var change = this;
this.prev = this.rev;
// get the current revision
this.currentRevision(function(err, rev) {
if(err) return Change.handleError(err, cb);
change.rev = rev;
change.save(cb);
});
}
/**
* Get a change's current revision based on current data.
* @callback {Function} callback
* @param {Error} err
* @param {String} rev The current revision
*/
Change.prototype.currentRevision = function(cb) {
var model = this.getModelCtor();
model.findById(this.modelId, function(err, inst) {
if(err) return Change.handleError(err, cb);
if(inst) {
cb(null, Change.revisionForInst(inst));
} else {
cb(null, null);
}
});
}
/**
* Create a hash of the given `string` with the `options.hashAlgorithm`.
* **Default: `sha1`**
*
* @param {String} str The string to be hashed
* @return {String} The hashed string
*/
Change.hash = function(str) {
return crypto
.createHash(Change.settings.hashAlgorithm || 'sha1')
.update(str)
.digest('hex');
}
/**
* Get the revision string for the given object
* @param {Object} inst The data to get the revision string for
* @return {String} The revision string
*/
Change.revisionForInst = function(inst) {
return this.hash(CJSON.stringify(inst));
}
/**
* Get a change's type. Returns one of:
*
* - `Change.UPDATE`
* - `Change.CREATE`
* - `Change.DELETE`
* - `Change.UNKNOWN`
*
* @return {String} the type of change
*/
Change.prototype.type = function() {
if(this.rev && this.prev) {
return Change.UPDATE;
}
if(this.rev && !this.prev) {
return Change.CREATE;
}
if(!this.rev && this.prev) {
return Change.DELETE;
}
return Change.UNKNOWN;
}
/**
* Get the `Model` class for `change.modelName`.
* @return {Model}
*/
Change.prototype.getModelCtor = function() {
// todo - not sure if this works with multiple data sources
return this.constructor.modelBuilder.models[this.modelName];
}
/**
* Compare two changes.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.equals = function(change) {
return change.rev === this.rev;
}
/**
* Determine if the change is based on the given change.
* @param {Change} change
* @return {Boolean}
*/
Change.prototype.isBasedOn = function(change) {
return this.prev === change.rev;
}
/**
* Determine the differences for a given model since a given checkpoint.
*
* The callback will contain an error or `result`.
*
* **result**
*
* ```js
* {
* deltas: Array,
* conflicts: Array
* }
* ```
*
* **deltas**
*
* An array of changes that differ from `remoteChanges`.
*
* **conflicts**
*
* An array of changes that conflict with `remoteChanges`.
*
* @param {String} modelName
* @param {Number} since Compare changes after this checkpoint
* @param {Change[]} remoteChanges A set of changes to compare
* @callback {Function} callback
* @param {Error} err
* @param {Object} result See above.
*/
Change.diff = function(modelName, since, remoteChanges, callback) {
var remoteChangeIndex = {};
var modelIds = [];
remoteChanges.forEach(function(ch) {
modelIds.push(ch.modelId);
remoteChangeIndex[ch.modelId] = new Change(ch);
});
// normalize `since`
since = Number(since) || 0;
this.find({
where: {
modelName: modelName,
modelId: {inq: modelIds},
checkpoint: {gt: since}
}
}, function(err, localChanges) {
if(err) return callback(err);
var deltas = [];
var conflicts = [];
localChanges.forEach(function(localChange) {
var remoteChange = remoteChangeIndex[localChange.modelId];
if(!localChange.equals(remoteChange)) {
if(remoteChange.isBasedOn(localChange)) {
deltas.push(remoteChange);
} else {
conflicts.push(localChange);
}
}
});
callback(null, {
deltas: deltas,
conflicts: conflicts
});
});
}

View File

@ -26,7 +26,8 @@
"underscore.string": "~2.3.3",
"underscore": "~1.5.2",
"uid2": "0.0.3",
"async": "~0.2.9"
"async": "~0.2.9",
"canonical-json": "0.0.3"
},
"peerDependencies": {
"loopback-datasource-juggler": "~1.2.11"

268
test/change.test.js Normal file
View File

@ -0,0 +1,268 @@
var Change;
var TestModel;
describe('Change', function(){
beforeEach(function() {
var memory = loopback.createDataSource({
connector: loopback.Memory
});
Change = loopback.Change.extend('change');
Change.attachTo(memory);
TestModel = loopback.Model.extend('chtest');
this.modelName = TestModel.modelName;
TestModel.attachTo(memory);
});
beforeEach(function(done) {
var test = this;
test.data = {
foo: 'bar'
};
TestModel.create(test.data, function(err, model) {
if(err) return done(err);
test.model = model;
test.modelId = model.id;
test.revisionForModel = Change.revisionForInst(model);
done();
});
});
describe('change.id', function () {
it('should be a hash of the modelName and modelId', function () {
var change = new Change({
rev: 'abc',
modelName: 'foo',
modelId: 'bar'
});
var hash = Change.hash([change.modelName, change.modelId].join('-'));
assert.equal(change.id, hash);
});
});
describe('Change.track(modelName, modelIds, callback)', function () {
describe('using an existing untracked model', function () {
beforeEach(function(done) {
var test = this;
Change.track(this.modelName, [this.modelId], function(err, trakedChagnes) {
if(err) return done(err);
test.trakedChagnes = trakedChagnes;
done();
});
});
it('should create an entry', function () {
assert(Array.isArray(this.trakedChagnes));
assert.equal(this.trakedChagnes[0].modelId, this.modelId);
});
it('should only create one change', function (done) {
Change.count(function(err, count) {
assert.equal(count, 1);
done();
});
});
});
});
describe('Change.findOrCreate(modelName, modelId, callback)', function () {
describe('when a change doesnt exist', function () {
beforeEach(function(done) {
var test = this;
Change.findOrCreate(this.modelName, this.modelId, function(err, result) {
if(err) return done(err);
test.result = result;
done();
});
});
it('should create an entry', function (done) {
var test = this;
Change.findById(this.result.id, function(err, change) {
assert.equal(change.id, test.result.id);
done();
});
});
});
describe('when a change does exist', function () {
beforeEach(function(done) {
var test = this;
Change.create({
modelName: test.modelName,
modelId: test.modelId
}, function(err, change) {
test.existingChange = change;
done();
});
});
beforeEach(function(done) {
var test = this;
Change.findOrCreate(this.modelName, this.modelId, function(err, result) {
if(err) return done(err);
test.result = result;
done();
});
});
it('should find the entry', function (done) {
var test = this;
assert.equal(test.existingChange.id, test.result.id);
done();
});
});
});
describe('change.rectify(callback)', function () {
it('should create a new change with the correct revision', function (done) {
var test = this;
var change = new Change({
modelName: this.modelName,
modelId: this.modelId
});
change.rectify(function(err, ch) {
assert.equal(ch.rev, test.revisionForModel);
done();
});
});
});
describe('change.currentRevision(callback)', function () {
it('should get the correct revision', function (done) {
var test = this;
var change = new Change({
modelName: this.modelName,
modelId: this.modelId
});
change.currentRevision(function(err, rev) {
assert.equal(rev, test.revisionForModel);
done();
});
});
});
describe('Change.hash(str)', function () {
// todo(ritch) test other hashing algorithms
it('should hash the given string', function () {
var str = 'foo';
var hash = Change.hash(str);
assert(hash !== str);
assert(typeof hash === 'string');
});
});
describe('Change.revisionForInst(inst)', function () {
it('should return the same revision for the same data', function () {
var a = {
b: {
b: ['c', 'd'],
c: ['d', 'e']
}
};
var b = {
b: {
c: ['d', 'e'],
b: ['c', 'd']
}
};
var aRev = Change.revisionForInst(a);
var bRev = Change.revisionForInst(b);
assert.equal(aRev, bRev);
});
});
describe('Change.type()', function () {
it('CREATE', function () {
var change = new Change({
rev: this.revisionForModel
});
assert.equal(Change.CREATE, change.type());
});
it('UPDATE', function () {
var change = new Change({
rev: this.revisionForModel,
prev: this.revisionForModel
});
assert.equal(Change.UPDATE, change.type());
});
it('DELETE', function () {
var change = new Change({
prev: this.revisionForModel
});
assert.equal(Change.DELETE, change.type());
});
it('UNKNOWN', function () {
var change = new Change();
assert.equal(Change.UNKNOWN, change.type());
});
});
describe('change.getModelCtor()', function () {
it('should get the correct model class', function () {
var change = new Change({
modelName: this.modelName
});
assert.equal(change.getModelCtor(), TestModel);
});
});
describe('change.equals(otherChange)', function () {
it('should return true when the change is equal', function () {
var change = new Change({
rev: this.revisionForModel
});
var otherChange = new Change({
rev: this.revisionForModel
});
assert.equal(change.equals(otherChange), true);
});
});
describe('change.isBasedOn(otherChange)', function () {
it('should return true when the change is based on the other', function () {
var change = new Change({
prev: this.revisionForModel
});
var otherChange = new Change({
rev: this.revisionForModel
});
assert.equal(change.isBasedOn(otherChange), true);
});
});
describe('Change.diff(modelName, since, remoteChanges, callback)', function () {
beforeEach(function(done) {
Change.create([
{rev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1},
{rev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1},
{rev: 'bat', modelName: this.modelName, modelId: 11, checkpoint: 1},
], done);
});
it('should return delta and conflict lists', function (done) {
var remoteChanges = [
{rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1},
{rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1},
{rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1},
];
Change.diff(this.modelName, 0, remoteChanges, function(err, diff) {
assert.equal(diff.deltas.length, 1);
assert.equal(diff.conflicts.length, 1);
done();
});
});
});
});