From 4be33952984c955e341a31b2f5afee36f7ab928e Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Sun, 26 Jan 2014 13:20:19 -0800 Subject: [PATCH 01/74] Add Change model --- docs.json | 1 + lib/loopback.js | 1 + lib/models/change.js | 325 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test/change.test.js | 268 +++++++++++++++++++++++++++++++++++ 5 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 lib/models/change.js create mode 100644 test/change.test.js diff --git a/docs.json b/docs.json index 2803f2db9..caa0b1891 100644 --- a/docs.json +++ b/docs.json @@ -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", diff --git a/lib/loopback.js b/lib/loopback.js index 724e9835a..fbc018573 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -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 diff --git a/lib/models/change.js b/lib/models/change.js new file mode 100644 index 000000000..199438cc1 --- /dev/null +++ b/lib/models/change.js @@ -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 + }); + }); +} diff --git a/package.json b/package.json index 90e1b38a3..ef2b38a57 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/test/change.test.js b/test/change.test.js new file mode 100644 index 000000000..3abebd383 --- /dev/null +++ b/test/change.test.js @@ -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(); + }); + }); + }); +}); From 1a13a8d95e114cea036a58b1700c94d73a517366 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Sun, 26 Jan 2014 14:02:56 -0800 Subject: [PATCH 02/74] Add Checkpoint model and Model replication methods --- lib/models/checkpoint.js | 56 +++++++++++ lib/models/model.js | 203 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 lib/models/checkpoint.js diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js new file mode 100644 index 000000000..f5ff74236 --- /dev/null +++ b/lib/models/checkpoint.js @@ -0,0 +1,56 @@ +/** + * Module Dependencies. + */ + +var Model = require('../loopback').Model + , loopback = require('../loopback') + , assert = require('assert'); + +/** + * Properties + */ + +var properties = { + id: {type: Number, generated: true, id: true}, + time: {type: Number, generated: true, default: Date.now}, + sourceId: {type: String} +}; + +/** + * Options + */ + +var options = { + +}; + +/** + * Checkpoint list entry. + * + * @property id {Number} the sequencial identifier of a checkpoint + * @property time {Number} the time when the checkpoint was created + * @property sourceId {String} the source identifier + * + * @class + * @inherits {Model} + */ + +var Checkpoint = module.exports = Model.extend('Checkpoint', properties, options); + +/** + * Get the current checkpoint id + * @callback {Function} callback + * @param {Error} err + * @param {Number} checkpointId The current checkpoint id + */ + +Checkpoint.current = function(cb) { + this.find({ + limit: 1, + sort: 'id DESC' + }, function(err, checkpoint) { + if(err) return cb(err); + cb(null, checkpoint.id); + }); +} + diff --git a/lib/models/model.js b/lib/models/model.js index ce2bb5f90..6e024a7d4 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -131,6 +131,7 @@ function getACL() { * @param {String|Error} err The error object * @param {Boolean} allowed is the request allowed */ + Model.checkAccess = function(token, modelId, method, callback) { var ANONYMOUS = require('./access-token').ANONYMOUS; token = token || ANONYMOUS; @@ -190,3 +191,205 @@ Model._getAccessTypeForMethod = function(method) { // setup the initial model Model.setup(); +/** + * Get a set of deltas and conflicts since the given checkpoint. + * + * See `Change.diff()` for details. + * + * @param {Number} since Find changes since this checkpoint + * @param {Array} remoteChanges An array of change objects + * @param {Function} callback + */ + +Model.diff = function(since, remoteChanges, callback) { + var Change = this.getChangeModel(); + Change.diff(this.modelName, since, remoteChanges, callback); +} + +/** + * Get the changes to a model since a given checkpoing. Provide a filter object + * to reduce the number of results returned. + * @param {Number} since Only return changes since this checkpoint + * @param {Object} filter Only include changes that match this filter + * (same as `Model.find(filter, ...)`) + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes An array of `Change` objects + * @end + */ + +Model.changes = function(since, filter, callback) { + var idName = this.idName(); + var Change = this.getChangeModel(); + var model = this; + + filter = filter || {}; + filter.fields = {}; + filter.where = filter.where || {}; + filter.fields[idName] = true; + + // this whole thing could be optimized a bit more + Change.find({ + checkpoint: {gt: since}, + modelName: this.modelName + }, function(err, changes) { + if(err) return cb(err); + var ids = changes.map(function(change) { + return change.modelId; + }); + filter.where[idName] = {inq: ids}; + model.find(filter, function(err, models) { + if(err) return cb(err); + var modelIds = models.map(function(m) { + return m[idName]; + }); + callback(null, changes.filter(function(ch) { + return modelIds.indexOf(ch.modelId) > -1; + })); + }); + }); +} + +/** + * Create a checkpoint. + * + * @param {Function} callback + */ + +Model.checkpoint = function(cb) { + var Checkpoint = this.getChangeModel().Checkpoint; + this.getSourceId(function(err, sourceId) { + if(err) return cb(err); + Checkpoint.create({ + sourceId: sourceId + }, cb); + }); +} + +/** + * Replicate changes since the given checkpoint to the given target model. + * + * @param {Number} since Since this checkpoint + * @param {Model} targetModel Target this model class + * @options {Object} options + * @property {Object} filter Replicate models that match this filter + * @callback {Function} callback + * @param {Error} err + * @param {Array} conflicts A list of changes that could not be replicated + * due to conflicts. + */ + +Model.replicate = function(since, targetModel, options, callback) { + var sourceModel = this; + var diff; + var updates; + + var tasks = [ + getLocalChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate, + sourceModel.checkpoint.bind(sourceModel) + ]; + + async.waterfall(tasks, function(err) { + if(err) return callback(err); + callback(null, diff.conflicts); + }); + + function getLocalChanges(cb) { + sourceModel.changes(since, options.filter, cb); + } + + function getDiffFromTarget(sourceChanges, cb) { + targetModel.diff(since, sourceChanges, cb); + } + + function createSourceUpdates(_diff, cb) { + diff = _diff; + sourceModel.createUpdates(diff.deltas, cb); + } + + function bulkUpdate(updates, cb) { + targetModel.bulkUpdate(updates, cb); + } +} + +/** + * Create an update list (for `Model.bulkUpdate()`) from a delta list + * (result of `Change.diff()`). + * + * @param {Array} deltas + * @param {Function} callback + */ + +Model.createUpdates = function(deltas, cb) { + var Change = this.getChangeModel(); + var updates = []; + var Model = this; + var tasks = []; + var type = change.type(); + + deltas.forEach(function(change) { + change = new Change(change); + var update = {type: type, change: change}; + switch(type) { + case Change.CREATE: + case Change.UPDATE: + tasks.push(function(cb) { + Model.findById(change.modelId, function(err, inst) { + if(err) return cb(err); + update.data = inst; + updates.push(update); + cb(); + }); + }); + break; + case Change.DELETE: + updates.push(update); + break; + } + }); + + async.parallel(tasks, function(err) { + if(err) return cb(err); + cb(null, updates); + }); +} + +/** + * Apply an update list. + * + * **Note: this is not atomic** + * + * @param {Array} updates An updates list (usually from Model.createUpdates()) + * @param {Function} callback + */ + +Model.bulkUpdate = function(updates, callback) { + var tasks = []; + var Model = this; + var idName = Model.idName(); + var Change = this.getChangeModel(); + + updates.forEach(function(update) { + switch(update.type) { + case Change.UPDATE: + case Change.CREATE: + tasks.push(Model.upsert.bind(Model, update.data)); + break; + case: Change.DELETE: + var data = {}; + data[idName] = update.change.modelId; + var model = new Model(data); + tasks.push(model.destroy.bind(model)); + break; + } + }); + + async.parallel(tasks, callback); +} + +Model.getChangeModel = function() { + +} From 2582c3fc65f0f1838345cef6349a0b66dad04b15 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 28 Jan 2014 12:54:41 -0800 Subject: [PATCH 03/74] Add replication example --- example/replication/app.js | 138 ++++++++++++++++++++++++++++ lib/models/change.js | 126 ++++++++++++++++++++++++-- lib/models/checkpoint.js | 3 +- lib/models/model.js | 180 ++++++++++++++++++++++++++++++++++--- 4 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 example/replication/app.js diff --git a/example/replication/app.js b/example/replication/app.js new file mode 100644 index 000000000..ab6e69870 --- /dev/null +++ b/example/replication/app.js @@ -0,0 +1,138 @@ +var loopback = require('../../'); +var app = loopback(); +var db = app.dataSource('db', {connector: loopback.Memory}); +var Color = app.model('color', {dataSource: 'db', options: {trackChanges: true}}); +var Color2 = app.model('color2', {dataSource: 'db', options: {trackChanges: true}}); +var target = Color2; +var source = Color; +var SPEED = process.env.SPEED || 100; +var conflicts; + +var steps = [ + + createSomeInitialSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + updateSomeTargetData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data '), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + updateSomeSourceDataCausingAConflict, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (now has a conflict)'), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + resolveAllConflicts, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (conflict resolved)'), + list.bind(this, target, 'current TARGET data (conflict resolved)'), + + createMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + createEvenMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + deleteAllSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (empty)'), + list.bind(this, target, 'current TARGET data (empty)'), + + createSomeNewSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data') +]; + +run(steps); + +function createSomeInitialSourceData() { + Color.create([ + {name: 'red'}, + {name: 'blue'}, + {name: 'green'} + ]); +} + +function replicateSourceToTarget() { + Color.replicate(0, Color2, {}, function(err, replicationConflicts) { + conflicts = replicationConflicts; + }); +} + +function resolveAllConflicts() { + if(conflicts.length) { + conflicts.forEach(function(conflict) { + conflict.resolve(); + }); + } +} + +function updateSomeTargetData() { + Color2.findById(1, function(err, color) { + color.name = 'conflict'; + color.save(); + }); +} + +function createMoreSourceData() { + Color.create({name: 'orange'}); +} + +function createEvenMoreSourceData() { + Color.create({name: 'black'}); +} + +function updateSomeSourceDataCausingAConflict() { + Color.findById(1, function(err, color) { + color.name = 'red!!!!'; + color.save(); + }); +} + +function deleteAllSourceData() { + Color.destroyAll(); +} + +function createSomeNewSourceData() { + Color.create([ + {name: 'violet'}, + {name: 'amber'}, + {name: 'olive'} + ]); +} + +function list(model, msg) { + console.log(msg); + model.find(function(err, items) { + items.forEach(function(item) { + console.log(' -', item.name); + }); + console.log(); + }); +} + +function run(steps) { + setInterval(function() { + var step = steps.shift(); + if(step) { + console.log(step.name); + step(); + } + }, SPEED); +} diff --git a/lib/models/change.js b/lib/models/change.js index 199438cc1..41e9b8760 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -27,7 +27,7 @@ var properties = { */ var options = { - + trackChanges: false }; /** @@ -55,6 +55,12 @@ Change.CREATE = 'create'; Change.DELETE = 'delete'; Change.UNKNOWN = 'unknown'; +/*! + * Conflict Class + */ + +Change.Conflict = Conflict; + /*! * Setup the extended model. */ @@ -149,13 +155,34 @@ Change.findOrCreate = function(modelName, modelId, callback) { 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; + var tasks = [ + updateRevision, + updateCheckpoint + ]; + + if(this.rev) this.prev = this.rev; + + async.parallel(tasks, function(err) { + if(err) return cb(err); change.save(cb); }); + + function updateRevision(cb) { + // get the current revision + change.currentRevision(function(err, rev) { + if(err) return Change.handleError(err, cb); + change.rev = rev; + cb(); + }); + } + + function updateCheckpoint(cb) { + change.constructor.getCheckpointModel().current(function(err, checkpoint) { + if(err) return Change.handleError(err); + change.checkpoint = ++checkpoint; + cb(); + }); + } } /** @@ -233,7 +260,7 @@ Change.prototype.type = function() { Change.prototype.getModelCtor = function() { // todo - not sure if this works with multiple data sources - return this.constructor.modelBuilder.models[this.modelName]; + return loopback.getModel(this.modelName); } /** @@ -306,7 +333,10 @@ Change.diff = function(modelName, since, remoteChanges, callback) { if(err) return callback(err); var deltas = []; var conflicts = []; + var localModelIds = []; + localChanges.forEach(function(localChange) { + localModelIds.push(localChange.modelId); var remoteChange = remoteChangeIndex[localChange.modelId]; if(!localChange.equals(remoteChange)) { if(remoteChange.isBasedOn(localChange)) { @@ -317,9 +347,91 @@ Change.diff = function(modelName, since, remoteChanges, callback) { } }); + modelIds.forEach(function(id) { + if(localModelIds.indexOf(id) === -1) { + deltas.push(remoteChangeIndex[id]); + } + }); + callback(null, { deltas: deltas, conflicts: conflicts }); }); } + +/** + * Correct all change list entries. + * @param {Function} callback + */ + +Change.rectifyAll = function(cb) { + // this should be optimized + this.find(function(err, changes) { + if(err) return cb(err); + changes.forEach(function(change) { + change.rectify(); + }); + }); +} + +/** + * Get the checkpoint model. + * @return {Checkpoint} + */ + +Change.getCheckpointModel = function() { + var checkpointModel = this.Checkpoint; + if(checkpointModel) return checkpointModel; + this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); + checkpointModel.attachTo(this.dataSource); + return checkpointModel; +} + + +/** + * When two changes conflict a conflict is created. + * + * **Note: call `conflict.fetch()` to get the `target` and `source` models. + * + * @param {Change} sourceChange The change object for the source model + * @param {Change} targetChange The conflicting model's change object + * @property {Model} source The source model instance + * @property {Model} target The target model instance + */ + +function Conflict(sourceChange, targetChange) { + this.sourceChange = sourceChange; + this.targetChange = targetChange; +} + +Conflict.prototype.fetch = function(cb) { + var conflict = this; + var tasks = [ + getSourceModel, + getTargetModel + ]; + + async.parallel(tasks, cb); + + function getSourceModel(change, cb) { + conflict.sourceModel.getModel(function(err, model) { + if(err) return cb(err); + conflict.source = model; + cb(); + }); + } + + function getTargetModel(cb) { + conflict.targetModel.getModel(function(err, model) { + if(err) return cb(err); + conflict.target = model; + cb(); + }); + } +} + +Conflict.prototype.resolve = function(cb) { + this.sourceChange.prev = this.targetChange.rev; + this.sourceChange.save(cb); +} diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index f5ff74236..22248bcb4 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -48,8 +48,9 @@ Checkpoint.current = function(cb) { this.find({ limit: 1, sort: 'id DESC' - }, function(err, checkpoint) { + }, function(err, checkpoints) { if(err) return cb(err); + var checkpoint = checkpoints[0] || {id: 0}; cb(null, checkpoint.id); }); } diff --git a/lib/models/model.js b/lib/models/model.js index 6e024a7d4..8c08d5d00 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -4,13 +4,76 @@ var loopback = require('../loopback'); var ModelBuilder = require('loopback-datasource-juggler').ModelBuilder; var modeler = new ModelBuilder(); +var async = require('async'); var assert = require('assert'); /** - * The built in loopback.Model. + * The base class for **all models**. * + * **Inheriting from `Model`** + * + * ```js + * var properties = {...}; + * var options = {...}; + * var MyModel = loopback.Model.extend('MyModel', properties, options); + * ``` + * + * **Options** + * + * - `trackChanges` - If true, changes to the model will be tracked. **Required + * for replication.** + * + * **Events** + * + * #### Event: `changed` + * + * Emitted after a model has been successfully created, saved, or updated. + * + * ```js + * MyModel.on('changed', function(inst) { + * console.log('model with id %s has been changed', inst.id); + * // => model with id 1 has been changed + * }); + * ``` + * + * #### Event: `deleted` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deleted', function(inst) { + * console.log('model with id %s has been deleted', inst.id); + * // => model with id 1 has been deleted + * }); + * ``` + * + * #### Event: `deletedAll` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deletedAll', function(where) { + * if(where) { + * console.log('all models where', where, 'have been deleted'); + * // => all models where + * // => {price: {gt: 100}} + * // => have been deleted + * } + * }); + * ``` + * + * #### Event: `attached` + * + * Emitted after a `Model` has been attached to an `app`. + * + * #### Event: `dataSourceAttached` + * + * Emitted after a `Model` has been attached to a `DataSource`. + * * @class * @param {Object} data + * @property {String} modelName The name of the model + * @property {DataSource} dataSource */ var Model = module.exports = modeler.define('Model'); @@ -23,6 +86,7 @@ Model.shared = true; Model.setup = function () { var ModelCtor = this; + var options = this.settings; ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { @@ -108,6 +172,13 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; + ModelCtor.once('dataSourceAttached', function() { + // enable change tracking (usually for replication) + if(options.trackChanges) { + ModelCtor.enableChangeTracking(); + } + }); + return ModelCtor; }; @@ -219,7 +290,7 @@ Model.diff = function(since, remoteChanges, callback) { */ Model.changes = function(since, filter, callback) { - var idName = this.idName(); + var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); var model = this; @@ -235,15 +306,16 @@ Model.changes = function(since, filter, callback) { }, function(err, changes) { if(err) return cb(err); var ids = changes.map(function(change) { - return change.modelId; + return change.modelId.toString(); }); filter.where[idName] = {inq: ids}; model.find(filter, function(err, models) { if(err) return cb(err); var modelIds = models.map(function(m) { - return m[idName]; + return m[idName].toString(); }); callback(null, changes.filter(function(ch) { + if(ch.type() === Change.DELETE) return true; return modelIds.indexOf(ch.modelId) > -1; })); }); @@ -257,7 +329,7 @@ Model.changes = function(since, filter, callback) { */ Model.checkpoint = function(cb) { - var Checkpoint = this.getChangeModel().Checkpoint; + var Checkpoint = this.getChangeModel().getCheckpointModel(); this.getSourceId(function(err, sourceId) { if(err) return cb(err); Checkpoint.create({ @@ -283,18 +355,29 @@ Model.replicate = function(since, targetModel, options, callback) { var sourceModel = this; var diff; var updates; + var Change = this.getChangeModel(); + var TargetChange = targetModel.getChangeModel(); var tasks = [ getLocalChanges, getDiffFromTarget, createSourceUpdates, bulkUpdate, - sourceModel.checkpoint.bind(sourceModel) + checkpoint ]; async.waterfall(tasks, function(err) { if(err) return callback(err); - callback(null, diff.conflicts); + var conflicts = diff.conflicts.map(function(change) { + var sourceChange = new Change({ + modelName: sourceModel.modelName, + modelId: change.modelId + }); + var targetChange = new TargetChange(change); + return new Change.Conflict(sourceChange, targetChange); + }); + + callback(null, conflicts); }); function getLocalChanges(cb) { @@ -307,12 +390,18 @@ Model.replicate = function(since, targetModel, options, callback) { function createSourceUpdates(_diff, cb) { diff = _diff; + diff.conflicts = diff.conflicts || []; sourceModel.createUpdates(diff.deltas, cb); } function bulkUpdate(updates, cb) { targetModel.bulkUpdate(updates, cb); } + + function checkpoint() { + var cb = arguments[arguments.length - 1]; + sourceModel.checkpoint(cb); + } } /** @@ -328,10 +417,10 @@ Model.createUpdates = function(deltas, cb) { var updates = []; var Model = this; var tasks = []; - var type = change.type(); deltas.forEach(function(change) { - change = new Change(change); + var change = new Change(change); + var type = change.type(); var update = {type: type, change: change}; switch(type) { case Change.CREATE: @@ -339,7 +428,11 @@ Model.createUpdates = function(deltas, cb) { tasks.push(function(cb) { Model.findById(change.modelId, function(err, inst) { if(err) return cb(err); - update.data = inst; + if(inst.toObject) { + update.data = inst.toObject(); + } else { + update.data = inst; + } updates.push(update); cb(); }); @@ -369,16 +462,22 @@ Model.createUpdates = function(deltas, cb) { Model.bulkUpdate = function(updates, callback) { var tasks = []; var Model = this; - var idName = Model.idName(); + var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); updates.forEach(function(update) { switch(update.type) { case Change.UPDATE: case Change.CREATE: - tasks.push(Model.upsert.bind(Model, update.data)); + // var model = new Model(update.data); + // tasks.push(model.save.bind(model)); + tasks.push(function(cb) { + var model = new Model(update.data); + debugger; + model.save(cb); + }); break; - case: Change.DELETE: + case Change.DELETE: var data = {}; data[idName] = update.change.modelId; var model = new Model(data); @@ -391,5 +490,58 @@ Model.bulkUpdate = function(updates, callback) { } Model.getChangeModel = function() { - + var changeModel = this.Change; + if(changeModel) return changeModel; + this.Change = changeModel = require('./change').extend(this.modelName + '-change'); + changeModel.attachTo(this.dataSource); + return changeModel; +} + +Model.getSourceId = function(cb) { + cb(null, 'foo') +} + +/** + * Enable the tracking of changes made to the model. Usually for replication. + */ + +Model.enableChangeTracking = function() { + var Model = this; + var Change = Model.getChangeModel(); + var cleanupInterval = Model.settings.changeCleanupInterval || 30000; + + Model.on('changed', function(obj) { + Change.track(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deleted', function(obj) { + Change.track(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deletedAll', cleanup); + + // initial cleanup + cleanup(); + + // cleanup + setInterval(cleanup, cleanupInterval); + + function cleanup() { + Change.rectifyAll(function(err) { + if(err) { + console.error(Model.modelName + ' Change Cleanup Error:'); + console.error(err); + } + }); + } } From a0e595dce8cdaa4d40398352a9662df47f293089 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 28 Jan 2014 14:32:13 -0800 Subject: [PATCH 04/74] Add model tests --- lib/models/model.js | 39 ++++++- test/change.test.js | 3 + test/model.test.js | 242 ++++++++++++++++++++++++++------------------ 3 files changed, 186 insertions(+), 98 deletions(-) diff --git a/lib/models/model.js b/lib/models/model.js index 8c08d5d00..8cc258326 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -338,6 +338,20 @@ Model.checkpoint = function(cb) { }); } +/** + * Get the current checkpoint id. + * + * @callback {Function} callback + * @param {Error} err + * @param {Number} currentCheckpointId + * @end + */ + +Model.currentCheckpoint = function(cb) { + var Checkpoint = this.getChangeModel().getCheckpointModel(); + Checkpoint.current(cb); +} + /** * Replicate changes since the given checkpoint to the given target model. * @@ -489,6 +503,12 @@ Model.bulkUpdate = function(updates, callback) { async.parallel(tasks, callback); } +/** + * Get the `Change` model. + * + * @return {Change} + */ + Model.getChangeModel = function() { var changeModel = this.Change; if(changeModel) return changeModel; @@ -497,8 +517,25 @@ Model.getChangeModel = function() { return changeModel; } +/** + * Get the source identifier for this model / dataSource. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} sourceId + */ + Model.getSourceId = function(cb) { - cb(null, 'foo') + var dataSource = this.dataSource; + if(!dataSource) { + this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); + } + assert( + dataSource.connector.name, + 'Model.getSourceId: cannot get id without dataSource.connector.name' + ); + var id = [dataSource.connector.name, this.modelName].join('-'); + cb(null, id); } /** diff --git a/test/change.test.js b/test/change.test.js index 3abebd383..358fbbfad 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -253,8 +253,11 @@ describe('Change', function(){ it('should return delta and conflict lists', function (done) { var remoteChanges = [ + // an update => should result in a delta {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + // no change => should not result in a delta / conflict {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + // a conflict => should result in a conflict {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, ]; diff --git a/test/model.test.js b/test/model.test.js index fe74b62bf..ca8e49998 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,4 +1,7 @@ -var ACL = require('../').ACL; +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; describe('Model', function() { @@ -523,33 +526,33 @@ describe('Model', function() { describe('Model.extend() events', function() { it('create isolated emitters for subclasses', function() { - var User1 = loopback.createModel('User1', { - 'first': String, - 'last': String - }); + var User1 = loopback.createModel('User1', { + 'first': String, + 'last': String + }); - var User2 = loopback.createModel('User2', { - 'name': String - }); + var User2 = loopback.createModel('User2', { + 'name': String + }); - var user1Triggered = false; - User1.once('x', function(event) { - user1Triggered = true; - }); + var user1Triggered = false; + User1.once('x', function(event) { + user1Triggered = true; + }); - var user2Triggered = false; - User2.once('x', function(event) { - user2Triggered = true; - }); + var user2Triggered = false; + User2.once('x', function(event) { + user2Triggered = true; + }); - assert(User1.once !== User2.once); - assert(User1.once !== loopback.Model.once); + assert(User1.once !== User2.once); + assert(User1.once !== loopback.Model.once); - User1.emit('x', User1); + User1.emit('x', User1); - assert(user1Triggered); - assert(!user2Triggered); + assert(user1Triggered); + assert(!user2Triggered); }); }); @@ -581,80 +584,125 @@ describe('Model', function() { } }); - // describe('Model.hasAndBelongsToMany()', function() { - // it("TODO: implement / document", function(done) { - // /* example - - // - // */ - // done(new Error('test not implemented')); - // }); - // }); - - // describe('Model.remoteMethods()', function() { - // it("Return a list of enabled remote methods", function() { - // app.model(User); - // User.remoteMethods(); // ['save', ...] - // }); - // }); - - // describe('Model.availableMethods()', function() { - // it("Returns the currently available api of a model as well as descriptions of any modified behavior or methods from attached data sources", function(done) { - // /* example - - // User.attachTo(oracle); - // console.log(User.availableMethods()); - // - // { - // 'User.all': { - // accepts: [{arg: 'filter', type: 'object', description: '...'}], - // returns: [{arg: 'users', type: ['User']}] - // }, - // 'User.find': { - // accepts: [{arg: 'id', type: 'any'}], - // returns: [{arg: 'items', type: 'User'}] - // }, - // ... - // } - // var oracle = loopback.createDataSource({ - // connector: 'oracle', - // host: '111.22.333.44', - // database: 'MYDB', - // username: 'username', - // password: 'password' - // }); - // - // */ - // done(new Error('test not implemented')); - // }); - // }); - -// describe('Model.before(name, fn)', function(){ -// it('Run a function before a method is called', function() { -// // User.before('save', function(user, next) { -// // console.log('about to save', user); -// // -// // next(); -// // }); -// // -// // User.before('delete', function(user, next) { -// // // prevent all delete calls -// // next(new Error('deleting is disabled')); -// // }); -// // User.beforeRemote('save', function(ctx, user, next) { -// // if(ctx.user.id === user.id) { -// // next(); -// // } else { -// // next(new Error('must be logged in to update')) -// // } -// // }); -// -// throw new Error('not implemented'); -// }); -// }); -// -// describe('Model.after(name, fn)', function(){ -// it('Run a function after a method is called', function() { -// -// throw new Error('not implemented'); -// }); -// }); + describe('Model.getChangeModel()', function() { + it('Get the Change Model', function () { + var UserChange = User.getChangeModel(); + var change = new UserChange(); + assert(change instanceof Change); + }); + }); + + describe('Model.getSourceId(callback)', function() { + it('Get the Source Id', function (done) { + User.getSourceId(function(err, id) { + assert.equal('memory-user', id); + done(); + }); + }); + }); + + describe('Model.checkpoint(callback)', function() { + it('Create a checkpoint', function (done) { + var Checkpoint = User.getChangeModel().getCheckpointModel(); + var tasks = [ + getCurrentCheckpoint, + checkpoint + ]; + var result; + var current; + + async.parallel(tasks, function(err) { + if(err) return done(err); + + assert.equal(result, current + 1); + done(); + }); + + function getCurrentCheckpoint(cb) { + Checkpoint.current(function(err, cp) { + current = cp; + cb(err); + }); + } + + function checkpoint(cb) { + User.checkpoint(function(err, cp) { + result = cp.id; + cb(err); + }); + } + }); + }); + + describe('Replication / Change APIs', function() { + beforeEach(function(done) { + var test = this; + this.dataSource = loopback.createDataSource({connector: loopback.Memory}); + var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { + trackChanges: true + }); + var TargetModel = this.TargetModel = this.dataSource.createModel('TargetModel', {}, { + trackChanges: true + }); + + var createOne = SourceModel.create.bind(SourceModel, { + name: 'baz' + }); + + async.parallel([ + createOne, + function(cb) { + SourceModel.currentCheckpoint(function(err, id) { + if(err) return cb(err); + test.startingCheckpoint = id; + cb(); + }); + } + ], process.nextTick.bind(process, done)); + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + }); + }); + }); }); From cc49d675ce9148804efeacba967043a1707d6852 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Sun, 26 Jan 2014 13:20:19 -0800 Subject: [PATCH 05/74] Add Change model --- docs.json | 1 + lib/loopback.js | 1 + lib/models/change.js | 325 +++++++++++++++++++++++++++++++++++++++++++ package.json | 3 +- test/change.test.js | 268 +++++++++++++++++++++++++++++++++++ 5 files changed, 597 insertions(+), 1 deletion(-) create mode 100644 lib/models/change.js create mode 100644 test/change.test.js diff --git a/docs.json b/docs.json index 2803f2db9..caa0b1891 100644 --- a/docs.json +++ b/docs.json @@ -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", diff --git a/lib/loopback.js b/lib/loopback.js index fd96b8ea8..23c0eb1c6 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -297,6 +297,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 diff --git a/lib/models/change.js b/lib/models/change.js new file mode 100644 index 000000000..199438cc1 --- /dev/null +++ b/lib/models/change.js @@ -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 + }); + }); +} diff --git a/package.json b/package.json index d504b4f68..96caa22a8 100644 --- a/package.json +++ b/package.json @@ -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.13" diff --git a/test/change.test.js b/test/change.test.js new file mode 100644 index 000000000..3abebd383 --- /dev/null +++ b/test/change.test.js @@ -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(); + }); + }); + }); +}); From e3d80058dc76ba53c9b30fe1fc763fbc1e955189 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Sun, 26 Jan 2014 14:02:56 -0800 Subject: [PATCH 06/74] Add Checkpoint model and Model replication methods --- lib/models/checkpoint.js | 56 +++++++++++ lib/models/model.js | 203 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 lib/models/checkpoint.js diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js new file mode 100644 index 000000000..f5ff74236 --- /dev/null +++ b/lib/models/checkpoint.js @@ -0,0 +1,56 @@ +/** + * Module Dependencies. + */ + +var Model = require('../loopback').Model + , loopback = require('../loopback') + , assert = require('assert'); + +/** + * Properties + */ + +var properties = { + id: {type: Number, generated: true, id: true}, + time: {type: Number, generated: true, default: Date.now}, + sourceId: {type: String} +}; + +/** + * Options + */ + +var options = { + +}; + +/** + * Checkpoint list entry. + * + * @property id {Number} the sequencial identifier of a checkpoint + * @property time {Number} the time when the checkpoint was created + * @property sourceId {String} the source identifier + * + * @class + * @inherits {Model} + */ + +var Checkpoint = module.exports = Model.extend('Checkpoint', properties, options); + +/** + * Get the current checkpoint id + * @callback {Function} callback + * @param {Error} err + * @param {Number} checkpointId The current checkpoint id + */ + +Checkpoint.current = function(cb) { + this.find({ + limit: 1, + sort: 'id DESC' + }, function(err, checkpoint) { + if(err) return cb(err); + cb(null, checkpoint.id); + }); +} + diff --git a/lib/models/model.js b/lib/models/model.js index 47fad80c9..a75068f2e 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -134,6 +134,7 @@ function getACL() { * @param {String|Error} err The error object * @param {Boolean} allowed is the request allowed */ + Model.checkAccess = function(token, modelId, method, callback) { var ANONYMOUS = require('./access-token').ANONYMOUS; token = token || ANONYMOUS; @@ -193,3 +194,205 @@ Model._getAccessTypeForMethod = function(method) { // setup the initial model Model.setup(); +/** + * Get a set of deltas and conflicts since the given checkpoint. + * + * See `Change.diff()` for details. + * + * @param {Number} since Find changes since this checkpoint + * @param {Array} remoteChanges An array of change objects + * @param {Function} callback + */ + +Model.diff = function(since, remoteChanges, callback) { + var Change = this.getChangeModel(); + Change.diff(this.modelName, since, remoteChanges, callback); +} + +/** + * Get the changes to a model since a given checkpoing. Provide a filter object + * to reduce the number of results returned. + * @param {Number} since Only return changes since this checkpoint + * @param {Object} filter Only include changes that match this filter + * (same as `Model.find(filter, ...)`) + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes An array of `Change` objects + * @end + */ + +Model.changes = function(since, filter, callback) { + var idName = this.idName(); + var Change = this.getChangeModel(); + var model = this; + + filter = filter || {}; + filter.fields = {}; + filter.where = filter.where || {}; + filter.fields[idName] = true; + + // this whole thing could be optimized a bit more + Change.find({ + checkpoint: {gt: since}, + modelName: this.modelName + }, function(err, changes) { + if(err) return cb(err); + var ids = changes.map(function(change) { + return change.modelId; + }); + filter.where[idName] = {inq: ids}; + model.find(filter, function(err, models) { + if(err) return cb(err); + var modelIds = models.map(function(m) { + return m[idName]; + }); + callback(null, changes.filter(function(ch) { + return modelIds.indexOf(ch.modelId) > -1; + })); + }); + }); +} + +/** + * Create a checkpoint. + * + * @param {Function} callback + */ + +Model.checkpoint = function(cb) { + var Checkpoint = this.getChangeModel().Checkpoint; + this.getSourceId(function(err, sourceId) { + if(err) return cb(err); + Checkpoint.create({ + sourceId: sourceId + }, cb); + }); +} + +/** + * Replicate changes since the given checkpoint to the given target model. + * + * @param {Number} since Since this checkpoint + * @param {Model} targetModel Target this model class + * @options {Object} options + * @property {Object} filter Replicate models that match this filter + * @callback {Function} callback + * @param {Error} err + * @param {Array} conflicts A list of changes that could not be replicated + * due to conflicts. + */ + +Model.replicate = function(since, targetModel, options, callback) { + var sourceModel = this; + var diff; + var updates; + + var tasks = [ + getLocalChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate, + sourceModel.checkpoint.bind(sourceModel) + ]; + + async.waterfall(tasks, function(err) { + if(err) return callback(err); + callback(null, diff.conflicts); + }); + + function getLocalChanges(cb) { + sourceModel.changes(since, options.filter, cb); + } + + function getDiffFromTarget(sourceChanges, cb) { + targetModel.diff(since, sourceChanges, cb); + } + + function createSourceUpdates(_diff, cb) { + diff = _diff; + sourceModel.createUpdates(diff.deltas, cb); + } + + function bulkUpdate(updates, cb) { + targetModel.bulkUpdate(updates, cb); + } +} + +/** + * Create an update list (for `Model.bulkUpdate()`) from a delta list + * (result of `Change.diff()`). + * + * @param {Array} deltas + * @param {Function} callback + */ + +Model.createUpdates = function(deltas, cb) { + var Change = this.getChangeModel(); + var updates = []; + var Model = this; + var tasks = []; + var type = change.type(); + + deltas.forEach(function(change) { + change = new Change(change); + var update = {type: type, change: change}; + switch(type) { + case Change.CREATE: + case Change.UPDATE: + tasks.push(function(cb) { + Model.findById(change.modelId, function(err, inst) { + if(err) return cb(err); + update.data = inst; + updates.push(update); + cb(); + }); + }); + break; + case Change.DELETE: + updates.push(update); + break; + } + }); + + async.parallel(tasks, function(err) { + if(err) return cb(err); + cb(null, updates); + }); +} + +/** + * Apply an update list. + * + * **Note: this is not atomic** + * + * @param {Array} updates An updates list (usually from Model.createUpdates()) + * @param {Function} callback + */ + +Model.bulkUpdate = function(updates, callback) { + var tasks = []; + var Model = this; + var idName = Model.idName(); + var Change = this.getChangeModel(); + + updates.forEach(function(update) { + switch(update.type) { + case Change.UPDATE: + case Change.CREATE: + tasks.push(Model.upsert.bind(Model, update.data)); + break; + case: Change.DELETE: + var data = {}; + data[idName] = update.change.modelId; + var model = new Model(data); + tasks.push(model.destroy.bind(model)); + break; + } + }); + + async.parallel(tasks, callback); +} + +Model.getChangeModel = function() { + +} From ab8d1ae109350a0bbef283321982718ef41211b1 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 28 Jan 2014 12:54:41 -0800 Subject: [PATCH 07/74] Add replication example --- example/replication/app.js | 138 ++++++++++++++++++++++++++++ lib/models/change.js | 126 ++++++++++++++++++++++++-- lib/models/checkpoint.js | 3 +- lib/models/model.js | 180 ++++++++++++++++++++++++++++++++++--- 4 files changed, 425 insertions(+), 22 deletions(-) create mode 100644 example/replication/app.js diff --git a/example/replication/app.js b/example/replication/app.js new file mode 100644 index 000000000..ab6e69870 --- /dev/null +++ b/example/replication/app.js @@ -0,0 +1,138 @@ +var loopback = require('../../'); +var app = loopback(); +var db = app.dataSource('db', {connector: loopback.Memory}); +var Color = app.model('color', {dataSource: 'db', options: {trackChanges: true}}); +var Color2 = app.model('color2', {dataSource: 'db', options: {trackChanges: true}}); +var target = Color2; +var source = Color; +var SPEED = process.env.SPEED || 100; +var conflicts; + +var steps = [ + + createSomeInitialSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + updateSomeTargetData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data '), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + updateSomeSourceDataCausingAConflict, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (now has a conflict)'), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + resolveAllConflicts, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (conflict resolved)'), + list.bind(this, target, 'current TARGET data (conflict resolved)'), + + createMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + createEvenMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + deleteAllSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (empty)'), + list.bind(this, target, 'current TARGET data (empty)'), + + createSomeNewSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data') +]; + +run(steps); + +function createSomeInitialSourceData() { + Color.create([ + {name: 'red'}, + {name: 'blue'}, + {name: 'green'} + ]); +} + +function replicateSourceToTarget() { + Color.replicate(0, Color2, {}, function(err, replicationConflicts) { + conflicts = replicationConflicts; + }); +} + +function resolveAllConflicts() { + if(conflicts.length) { + conflicts.forEach(function(conflict) { + conflict.resolve(); + }); + } +} + +function updateSomeTargetData() { + Color2.findById(1, function(err, color) { + color.name = 'conflict'; + color.save(); + }); +} + +function createMoreSourceData() { + Color.create({name: 'orange'}); +} + +function createEvenMoreSourceData() { + Color.create({name: 'black'}); +} + +function updateSomeSourceDataCausingAConflict() { + Color.findById(1, function(err, color) { + color.name = 'red!!!!'; + color.save(); + }); +} + +function deleteAllSourceData() { + Color.destroyAll(); +} + +function createSomeNewSourceData() { + Color.create([ + {name: 'violet'}, + {name: 'amber'}, + {name: 'olive'} + ]); +} + +function list(model, msg) { + console.log(msg); + model.find(function(err, items) { + items.forEach(function(item) { + console.log(' -', item.name); + }); + console.log(); + }); +} + +function run(steps) { + setInterval(function() { + var step = steps.shift(); + if(step) { + console.log(step.name); + step(); + } + }, SPEED); +} diff --git a/lib/models/change.js b/lib/models/change.js index 199438cc1..41e9b8760 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -27,7 +27,7 @@ var properties = { */ var options = { - + trackChanges: false }; /** @@ -55,6 +55,12 @@ Change.CREATE = 'create'; Change.DELETE = 'delete'; Change.UNKNOWN = 'unknown'; +/*! + * Conflict Class + */ + +Change.Conflict = Conflict; + /*! * Setup the extended model. */ @@ -149,13 +155,34 @@ Change.findOrCreate = function(modelName, modelId, callback) { 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; + var tasks = [ + updateRevision, + updateCheckpoint + ]; + + if(this.rev) this.prev = this.rev; + + async.parallel(tasks, function(err) { + if(err) return cb(err); change.save(cb); }); + + function updateRevision(cb) { + // get the current revision + change.currentRevision(function(err, rev) { + if(err) return Change.handleError(err, cb); + change.rev = rev; + cb(); + }); + } + + function updateCheckpoint(cb) { + change.constructor.getCheckpointModel().current(function(err, checkpoint) { + if(err) return Change.handleError(err); + change.checkpoint = ++checkpoint; + cb(); + }); + } } /** @@ -233,7 +260,7 @@ Change.prototype.type = function() { Change.prototype.getModelCtor = function() { // todo - not sure if this works with multiple data sources - return this.constructor.modelBuilder.models[this.modelName]; + return loopback.getModel(this.modelName); } /** @@ -306,7 +333,10 @@ Change.diff = function(modelName, since, remoteChanges, callback) { if(err) return callback(err); var deltas = []; var conflicts = []; + var localModelIds = []; + localChanges.forEach(function(localChange) { + localModelIds.push(localChange.modelId); var remoteChange = remoteChangeIndex[localChange.modelId]; if(!localChange.equals(remoteChange)) { if(remoteChange.isBasedOn(localChange)) { @@ -317,9 +347,91 @@ Change.diff = function(modelName, since, remoteChanges, callback) { } }); + modelIds.forEach(function(id) { + if(localModelIds.indexOf(id) === -1) { + deltas.push(remoteChangeIndex[id]); + } + }); + callback(null, { deltas: deltas, conflicts: conflicts }); }); } + +/** + * Correct all change list entries. + * @param {Function} callback + */ + +Change.rectifyAll = function(cb) { + // this should be optimized + this.find(function(err, changes) { + if(err) return cb(err); + changes.forEach(function(change) { + change.rectify(); + }); + }); +} + +/** + * Get the checkpoint model. + * @return {Checkpoint} + */ + +Change.getCheckpointModel = function() { + var checkpointModel = this.Checkpoint; + if(checkpointModel) return checkpointModel; + this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); + checkpointModel.attachTo(this.dataSource); + return checkpointModel; +} + + +/** + * When two changes conflict a conflict is created. + * + * **Note: call `conflict.fetch()` to get the `target` and `source` models. + * + * @param {Change} sourceChange The change object for the source model + * @param {Change} targetChange The conflicting model's change object + * @property {Model} source The source model instance + * @property {Model} target The target model instance + */ + +function Conflict(sourceChange, targetChange) { + this.sourceChange = sourceChange; + this.targetChange = targetChange; +} + +Conflict.prototype.fetch = function(cb) { + var conflict = this; + var tasks = [ + getSourceModel, + getTargetModel + ]; + + async.parallel(tasks, cb); + + function getSourceModel(change, cb) { + conflict.sourceModel.getModel(function(err, model) { + if(err) return cb(err); + conflict.source = model; + cb(); + }); + } + + function getTargetModel(cb) { + conflict.targetModel.getModel(function(err, model) { + if(err) return cb(err); + conflict.target = model; + cb(); + }); + } +} + +Conflict.prototype.resolve = function(cb) { + this.sourceChange.prev = this.targetChange.rev; + this.sourceChange.save(cb); +} diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index f5ff74236..22248bcb4 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -48,8 +48,9 @@ Checkpoint.current = function(cb) { this.find({ limit: 1, sort: 'id DESC' - }, function(err, checkpoint) { + }, function(err, checkpoints) { if(err) return cb(err); + var checkpoint = checkpoints[0] || {id: 0}; cb(null, checkpoint.id); }); } diff --git a/lib/models/model.js b/lib/models/model.js index a75068f2e..71ba5718e 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -5,13 +5,76 @@ var loopback = require('../loopback'); var compat = require('../compat'); var ModelBuilder = require('loopback-datasource-juggler').ModelBuilder; var modeler = new ModelBuilder(); +var async = require('async'); var assert = require('assert'); /** - * The built in loopback.Model. + * The base class for **all models**. * + * **Inheriting from `Model`** + * + * ```js + * var properties = {...}; + * var options = {...}; + * var MyModel = loopback.Model.extend('MyModel', properties, options); + * ``` + * + * **Options** + * + * - `trackChanges` - If true, changes to the model will be tracked. **Required + * for replication.** + * + * **Events** + * + * #### Event: `changed` + * + * Emitted after a model has been successfully created, saved, or updated. + * + * ```js + * MyModel.on('changed', function(inst) { + * console.log('model with id %s has been changed', inst.id); + * // => model with id 1 has been changed + * }); + * ``` + * + * #### Event: `deleted` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deleted', function(inst) { + * console.log('model with id %s has been deleted', inst.id); + * // => model with id 1 has been deleted + * }); + * ``` + * + * #### Event: `deletedAll` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deletedAll', function(where) { + * if(where) { + * console.log('all models where', where, 'have been deleted'); + * // => all models where + * // => {price: {gt: 100}} + * // => have been deleted + * } + * }); + * ``` + * + * #### Event: `attached` + * + * Emitted after a `Model` has been attached to an `app`. + * + * #### Event: `dataSourceAttached` + * + * Emitted after a `Model` has been attached to a `DataSource`. + * * @class * @param {Object} data + * @property {String} modelName The name of the model + * @property {DataSource} dataSource */ var Model = module.exports = modeler.define('Model'); @@ -24,6 +87,7 @@ Model.shared = true; Model.setup = function () { var ModelCtor = this; + var options = this.settings; ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { @@ -111,6 +175,13 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; + ModelCtor.once('dataSourceAttached', function() { + // enable change tracking (usually for replication) + if(options.trackChanges) { + ModelCtor.enableChangeTracking(); + } + }); + return ModelCtor; }; @@ -222,7 +293,7 @@ Model.diff = function(since, remoteChanges, callback) { */ Model.changes = function(since, filter, callback) { - var idName = this.idName(); + var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); var model = this; @@ -238,15 +309,16 @@ Model.changes = function(since, filter, callback) { }, function(err, changes) { if(err) return cb(err); var ids = changes.map(function(change) { - return change.modelId; + return change.modelId.toString(); }); filter.where[idName] = {inq: ids}; model.find(filter, function(err, models) { if(err) return cb(err); var modelIds = models.map(function(m) { - return m[idName]; + return m[idName].toString(); }); callback(null, changes.filter(function(ch) { + if(ch.type() === Change.DELETE) return true; return modelIds.indexOf(ch.modelId) > -1; })); }); @@ -260,7 +332,7 @@ Model.changes = function(since, filter, callback) { */ Model.checkpoint = function(cb) { - var Checkpoint = this.getChangeModel().Checkpoint; + var Checkpoint = this.getChangeModel().getCheckpointModel(); this.getSourceId(function(err, sourceId) { if(err) return cb(err); Checkpoint.create({ @@ -286,18 +358,29 @@ Model.replicate = function(since, targetModel, options, callback) { var sourceModel = this; var diff; var updates; + var Change = this.getChangeModel(); + var TargetChange = targetModel.getChangeModel(); var tasks = [ getLocalChanges, getDiffFromTarget, createSourceUpdates, bulkUpdate, - sourceModel.checkpoint.bind(sourceModel) + checkpoint ]; async.waterfall(tasks, function(err) { if(err) return callback(err); - callback(null, diff.conflicts); + var conflicts = diff.conflicts.map(function(change) { + var sourceChange = new Change({ + modelName: sourceModel.modelName, + modelId: change.modelId + }); + var targetChange = new TargetChange(change); + return new Change.Conflict(sourceChange, targetChange); + }); + + callback(null, conflicts); }); function getLocalChanges(cb) { @@ -310,12 +393,18 @@ Model.replicate = function(since, targetModel, options, callback) { function createSourceUpdates(_diff, cb) { diff = _diff; + diff.conflicts = diff.conflicts || []; sourceModel.createUpdates(diff.deltas, cb); } function bulkUpdate(updates, cb) { targetModel.bulkUpdate(updates, cb); } + + function checkpoint() { + var cb = arguments[arguments.length - 1]; + sourceModel.checkpoint(cb); + } } /** @@ -331,10 +420,10 @@ Model.createUpdates = function(deltas, cb) { var updates = []; var Model = this; var tasks = []; - var type = change.type(); deltas.forEach(function(change) { - change = new Change(change); + var change = new Change(change); + var type = change.type(); var update = {type: type, change: change}; switch(type) { case Change.CREATE: @@ -342,7 +431,11 @@ Model.createUpdates = function(deltas, cb) { tasks.push(function(cb) { Model.findById(change.modelId, function(err, inst) { if(err) return cb(err); - update.data = inst; + if(inst.toObject) { + update.data = inst.toObject(); + } else { + update.data = inst; + } updates.push(update); cb(); }); @@ -372,16 +465,22 @@ Model.createUpdates = function(deltas, cb) { Model.bulkUpdate = function(updates, callback) { var tasks = []; var Model = this; - var idName = Model.idName(); + var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); updates.forEach(function(update) { switch(update.type) { case Change.UPDATE: case Change.CREATE: - tasks.push(Model.upsert.bind(Model, update.data)); + // var model = new Model(update.data); + // tasks.push(model.save.bind(model)); + tasks.push(function(cb) { + var model = new Model(update.data); + debugger; + model.save(cb); + }); break; - case: Change.DELETE: + case Change.DELETE: var data = {}; data[idName] = update.change.modelId; var model = new Model(data); @@ -394,5 +493,58 @@ Model.bulkUpdate = function(updates, callback) { } Model.getChangeModel = function() { - + var changeModel = this.Change; + if(changeModel) return changeModel; + this.Change = changeModel = require('./change').extend(this.modelName + '-change'); + changeModel.attachTo(this.dataSource); + return changeModel; +} + +Model.getSourceId = function(cb) { + cb(null, 'foo') +} + +/** + * Enable the tracking of changes made to the model. Usually for replication. + */ + +Model.enableChangeTracking = function() { + var Model = this; + var Change = Model.getChangeModel(); + var cleanupInterval = Model.settings.changeCleanupInterval || 30000; + + Model.on('changed', function(obj) { + Change.track(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deleted', function(obj) { + Change.track(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deletedAll', cleanup); + + // initial cleanup + cleanup(); + + // cleanup + setInterval(cleanup, cleanupInterval); + + function cleanup() { + Change.rectifyAll(function(err) { + if(err) { + console.error(Model.modelName + ' Change Cleanup Error:'); + console.error(err); + } + }); + } } From 13f67385d0d521d9de88e2084011ec1f75b8256d Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 28 Jan 2014 14:32:13 -0800 Subject: [PATCH 08/74] Add model tests --- lib/models/model.js | 39 ++++++- test/change.test.js | 3 + test/model.test.js | 242 ++++++++++++++++++++++++++------------------ 3 files changed, 186 insertions(+), 98 deletions(-) diff --git a/lib/models/model.js b/lib/models/model.js index 71ba5718e..2214a1163 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -341,6 +341,20 @@ Model.checkpoint = function(cb) { }); } +/** + * Get the current checkpoint id. + * + * @callback {Function} callback + * @param {Error} err + * @param {Number} currentCheckpointId + * @end + */ + +Model.currentCheckpoint = function(cb) { + var Checkpoint = this.getChangeModel().getCheckpointModel(); + Checkpoint.current(cb); +} + /** * Replicate changes since the given checkpoint to the given target model. * @@ -492,6 +506,12 @@ Model.bulkUpdate = function(updates, callback) { async.parallel(tasks, callback); } +/** + * Get the `Change` model. + * + * @return {Change} + */ + Model.getChangeModel = function() { var changeModel = this.Change; if(changeModel) return changeModel; @@ -500,8 +520,25 @@ Model.getChangeModel = function() { return changeModel; } +/** + * Get the source identifier for this model / dataSource. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} sourceId + */ + Model.getSourceId = function(cb) { - cb(null, 'foo') + var dataSource = this.dataSource; + if(!dataSource) { + this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); + } + assert( + dataSource.connector.name, + 'Model.getSourceId: cannot get id without dataSource.connector.name' + ); + var id = [dataSource.connector.name, this.modelName].join('-'); + cb(null, id); } /** diff --git a/test/change.test.js b/test/change.test.js index 3abebd383..358fbbfad 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -253,8 +253,11 @@ describe('Change', function(){ it('should return delta and conflict lists', function (done) { var remoteChanges = [ + // an update => should result in a delta {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + // no change => should not result in a delta / conflict {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + // a conflict => should result in a conflict {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, ]; diff --git a/test/model.test.js b/test/model.test.js index 8cb112abf..9eec0e492 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,4 +1,7 @@ -var ACL = require('../').ACL; +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; describe('Model', function() { @@ -554,33 +557,33 @@ describe('Model', function() { describe('Model.extend() events', function() { it('create isolated emitters for subclasses', function() { - var User1 = loopback.createModel('User1', { - 'first': String, - 'last': String - }); + var User1 = loopback.createModel('User1', { + 'first': String, + 'last': String + }); - var User2 = loopback.createModel('User2', { - 'name': String - }); + var User2 = loopback.createModel('User2', { + 'name': String + }); - var user1Triggered = false; - User1.once('x', function(event) { - user1Triggered = true; - }); + var user1Triggered = false; + User1.once('x', function(event) { + user1Triggered = true; + }); - var user2Triggered = false; - User2.once('x', function(event) { - user2Triggered = true; - }); + var user2Triggered = false; + User2.once('x', function(event) { + user2Triggered = true; + }); - assert(User1.once !== User2.once); - assert(User1.once !== loopback.Model.once); + assert(User1.once !== User2.once); + assert(User1.once !== loopback.Model.once); - User1.emit('x', User1); + User1.emit('x', User1); - assert(user1Triggered); - assert(!user2Triggered); + assert(user1Triggered); + assert(!user2Triggered); }); }); @@ -612,80 +615,125 @@ describe('Model', function() { } }); - // describe('Model.hasAndBelongsToMany()', function() { - // it("TODO: implement / document", function(done) { - // /* example - - // - // */ - // done(new Error('test not implemented')); - // }); - // }); - - // describe('Model.remoteMethods()', function() { - // it("Return a list of enabled remote methods", function() { - // app.model(User); - // User.remoteMethods(); // ['save', ...] - // }); - // }); - - // describe('Model.availableMethods()', function() { - // it("Returns the currently available api of a model as well as descriptions of any modified behavior or methods from attached data sources", function(done) { - // /* example - - // User.attachTo(oracle); - // console.log(User.availableMethods()); - // - // { - // 'User.all': { - // accepts: [{arg: 'filter', type: 'object', description: '...'}], - // returns: [{arg: 'users', type: ['User']}] - // }, - // 'User.find': { - // accepts: [{arg: 'id', type: 'any'}], - // returns: [{arg: 'items', type: 'User'}] - // }, - // ... - // } - // var oracle = loopback.createDataSource({ - // connector: 'oracle', - // host: '111.22.333.44', - // database: 'MYDB', - // username: 'username', - // password: 'password' - // }); - // - // */ - // done(new Error('test not implemented')); - // }); - // }); - -// describe('Model.before(name, fn)', function(){ -// it('Run a function before a method is called', function() { -// // User.before('save', function(user, next) { -// // console.log('about to save', user); -// // -// // next(); -// // }); -// // -// // User.before('delete', function(user, next) { -// // // prevent all delete calls -// // next(new Error('deleting is disabled')); -// // }); -// // User.beforeRemote('save', function(ctx, user, next) { -// // if(ctx.user.id === user.id) { -// // next(); -// // } else { -// // next(new Error('must be logged in to update')) -// // } -// // }); -// -// throw new Error('not implemented'); -// }); -// }); -// -// describe('Model.after(name, fn)', function(){ -// it('Run a function after a method is called', function() { -// -// throw new Error('not implemented'); -// }); -// }); + describe('Model.getChangeModel()', function() { + it('Get the Change Model', function () { + var UserChange = User.getChangeModel(); + var change = new UserChange(); + assert(change instanceof Change); + }); + }); + + describe('Model.getSourceId(callback)', function() { + it('Get the Source Id', function (done) { + User.getSourceId(function(err, id) { + assert.equal('memory-user', id); + done(); + }); + }); + }); + + describe('Model.checkpoint(callback)', function() { + it('Create a checkpoint', function (done) { + var Checkpoint = User.getChangeModel().getCheckpointModel(); + var tasks = [ + getCurrentCheckpoint, + checkpoint + ]; + var result; + var current; + + async.parallel(tasks, function(err) { + if(err) return done(err); + + assert.equal(result, current + 1); + done(); + }); + + function getCurrentCheckpoint(cb) { + Checkpoint.current(function(err, cp) { + current = cp; + cb(err); + }); + } + + function checkpoint(cb) { + User.checkpoint(function(err, cp) { + result = cp.id; + cb(err); + }); + } + }); + }); + + describe('Replication / Change APIs', function() { + beforeEach(function(done) { + var test = this; + this.dataSource = loopback.createDataSource({connector: loopback.Memory}); + var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { + trackChanges: true + }); + var TargetModel = this.TargetModel = this.dataSource.createModel('TargetModel', {}, { + trackChanges: true + }); + + var createOne = SourceModel.create.bind(SourceModel, { + name: 'baz' + }); + + async.parallel([ + createOne, + function(cb) { + SourceModel.currentCheckpoint(function(err, id) { + if(err) return cb(err); + test.startingCheckpoint = id; + cb(); + }); + } + ], process.nextTick.bind(process, done)); + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + }); + }); + }); }); From 0bf7af104d6dd5b1bdb6bb9eabf9183131fcff96 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 5 Feb 2014 15:27:58 -0800 Subject: [PATCH 09/74] fixup! rename Change.track => rectifyModelChanges --- lib/models/change.js | 2 +- lib/models/model.js | 4 ++-- test/change.test.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/models/change.js b/lib/models/change.js index 41e9b8760..1328a9647 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -88,7 +88,7 @@ Change.setup(); * @param {Array} changes Changes that were tracked */ -Change.track = function(modelName, modelIds, callback) { +Change.rectifyModelChanges = function(modelName, modelIds, callback) { var tasks = []; var Change = this; diff --git a/lib/models/model.js b/lib/models/model.js index 2214a1163..30277d38e 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -551,7 +551,7 @@ Model.enableChangeTracking = function() { var cleanupInterval = Model.settings.changeCleanupInterval || 30000; Model.on('changed', function(obj) { - Change.track(Model.modelName, [obj.id], function(err) { + Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { if(err) { console.error(Model.modelName + ' Change Tracking Error:'); console.error(err); @@ -560,7 +560,7 @@ Model.enableChangeTracking = function() { }); Model.on('deleted', function(obj) { - Change.track(Model.modelName, [obj.id], function(err) { + Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { if(err) { console.error(Model.modelName + ' Change Tracking Error:'); console.error(err); diff --git a/test/change.test.js b/test/change.test.js index 358fbbfad..fbc24739f 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -42,11 +42,11 @@ describe('Change', function(){ }); }); - describe('Change.track(modelName, modelIds, callback)', function () { + describe('Change.rectifyModelChanges(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) { + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trakedChagnes) { if(err) return done(err); test.trakedChagnes = trakedChagnes; done(); From 867e3ca996c02b36488c6b91ea1edc06c87f8240 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 5 Feb 2014 15:32:38 -0800 Subject: [PATCH 10/74] fixup! Assert model exists --- lib/models/change.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/change.js b/lib/models/change.js index 1328a9647..55c0155ad 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -127,6 +127,7 @@ Change.idForModel = function(modelName, modelId) { */ Change.findOrCreate = function(modelName, modelId, callback) { + assert(loopback.getModel(modelName), modelName + ' does not exist'); var id = this.idForModel(modelName, modelId); var Change = this; From b660f8a59ec9a2362e4715d61fb1ad63d8f9a56b Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 16 Apr 2014 07:33:17 -0700 Subject: [PATCH 11/74] Add replication e2e tests --- Gruntfile.js | 3 +- lib/connectors/base-connector.js | 2 +- lib/connectors/memory.js | 2 +- lib/connectors/remote.js | 3 ++ lib/models/change.js | 2 ++ lib/models/model.js | 3 ++ test/e2e/replication.e2e.js | 62 ++++++++++++++++++++++++++++++++ 7 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 test/e2e/replication.e2e.js diff --git a/Gruntfile.js b/Gruntfile.js index cfd7b8734..2d3626896 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -143,7 +143,8 @@ module.exports = function(grunt) { // list of files / patterns to load in the browser files: [ - 'test/e2e/remote-connector.e2e.js' + 'test/e2e/remote-connector.e2e.js', + 'test/e2e/replication.e2e.js' ], // list of files to exclude diff --git a/lib/connectors/base-connector.js b/lib/connectors/base-connector.js index 763082ecf..75ee55a21 100644 --- a/lib/connectors/base-connector.js +++ b/lib/connectors/base-connector.js @@ -51,4 +51,4 @@ Connector._createJDBAdapter = function (jdbModule) { Connector.prototype._addCrudOperationsFromJDBAdapter = function (connector) { -} \ No newline at end of file +} diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 0b92e0c64..08f2a2f6b 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -36,4 +36,4 @@ inherits(Memory, Connector); * JugglingDB Compatibility */ -Memory.initialize = JdbMemory.initialize; \ No newline at end of file +Memory.initialize = JdbMemory.initialize; diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 065682f6f..607f79afd 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -51,6 +51,9 @@ RemoteConnector.prototype.define = function(definition) { var url = this.url; var adapter = this.adapter; + assert(Model.app, 'Cannot attach Model: ' + Model.modelName + + ' to a RemoteConnector. You must first attach it to an app!'); + Model.remotes(function(err, remotes) { var sharedClass = getSharedClass(remotes, className); remotes.connect(url, adapter); diff --git a/lib/models/change.js b/lib/models/change.js index 41e9b8760..38d24f9d9 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -384,6 +384,8 @@ Change.getCheckpointModel = function() { var checkpointModel = this.Checkpoint; if(checkpointModel) return checkpointModel; this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); + assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName + + ' is not attached to a dataSource'); checkpointModel.attachTo(this.dataSource); return checkpointModel; } diff --git a/lib/models/model.js b/lib/models/model.js index 6365a9c0f..bd305c5d0 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -593,6 +593,9 @@ Model.getChangeModel = function() { var changeModel = this.Change; if(changeModel) return changeModel; this.Change = changeModel = require('./change').extend(this.modelName + '-change'); + + assert(this.dataSource, 'Cannot getChangeModel(): ' + this.modelName + + ' is not attached to a dataSource'); changeModel.attachTo(this.dataSource); return changeModel; } diff --git a/test/e2e/replication.e2e.js b/test/e2e/replication.e2e.js new file mode 100644 index 000000000..e38264a77 --- /dev/null +++ b/test/e2e/replication.e2e.js @@ -0,0 +1,62 @@ +var path = require('path'); +var loopback = require('../../'); +var models = require('../fixtures/e2e/models'); +var TestModel = models.TestModel; +var LocalTestModel = TestModel.extend('LocalTestModel'); +var assert = require('assert'); + +describe('ReplicationModel', function () { + it('ReplicationModel.enableChangeTracking()', function (done) { + var TestReplicationModel = loopback.DataModel.extend('TestReplicationModel'); + var remote = loopback.createDataSource({ + url: 'http://localhost:3000/api', + connector: loopback.Remote + }); + var testApp = loopback(); + testApp.model(TestReplicationModel); + TestReplicationModel.attachTo(remote); + // chicken-egg condition + // getChangeModel() requires it to be attached to an app + // attaching to the app requires getChangeModel() + var Change = TestReplicationModel.getChangeModel(); + testApp.model(Change); + Change.attachTo(remote); + TestReplicationModel.enableChangeTracking(); + }); +}); + +describe.skip('Replication', function() { + beforeEach(function() { + // setup the remote connector + var localApp = loopback(); + var ds = loopback.createDataSource({ + url: 'http://localhost:3000/api', + connector: loopback.Remote + }); + localApp.model(TestModel); + localApp.model(LocalTestModel); + TestModel.attachTo(ds); + var memory = loopback.memory(); + LocalTestModel.attachTo(memory); + + // TODO(ritch) this should be internal... + LocalTestModel.getChangeModel().attachTo(memory); + + LocalTestModel.enableChangeTracking(); + + // failing because change model is not properly attached + TestModel.enableChangeTracking(); + }); + + it('should replicate local data to the remote', function (done) { + LocalTestModel.create({ + foo: 'bar' + }, function() { + LocalTestModel.replicate(0, TestModel, function() { + console.log('replicated'); + done(); + }); + }); + }); + +}); From e35309ba27e32bb20d9d888ca31221cbcffc0844 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 29 Apr 2014 17:17:49 -0700 Subject: [PATCH 12/74] Fixes for e2e replication / remote connector tests --- lib/connectors/remote.js | 67 ++++++++++++------ lib/models/change.js | 8 ++- lib/models/model.js | 112 ++++++++++++++----------------- package.json | 3 +- test/e2e/remote-connector.e2e.js | 2 - test/e2e/replication.e2e.js | 53 ++++----------- test/fixtures/e2e/app.js | 10 ++- test/fixtures/e2e/models.js | 4 +- test/model.test.js | 2 + 9 files changed, 132 insertions(+), 129 deletions(-) diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 607f79afd..1a252e94b 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -2,9 +2,9 @@ * Dependencies. */ -var assert = require('assert') - , compat = require('../compat') - , _ = require('underscore'); +var assert = require('assert'); +var remoting = require('strong-remoting'); +var compat = require('../compat'); /** * Export the RemoteConnector class. @@ -24,6 +24,7 @@ function RemoteConnector(settings) { this.root = settings.root || ''; this.host = settings.host || 'localhost'; this.port = settings.port || 3000; + this.remotes = remoting.create(); if(settings.url) { this.url = settings.url; @@ -36,9 +37,9 @@ function RemoteConnector(settings) { } RemoteConnector.prototype.connect = function() { + this.remotes.connect(this.url, this.adapter); } - RemoteConnector.initialize = function(dataSource, callback) { var connector = dataSource.connector = new RemoteConnector(dataSource.settings); connector.connect(); @@ -48,24 +49,52 @@ RemoteConnector.initialize = function(dataSource, callback) { RemoteConnector.prototype.define = function(definition) { var Model = definition.model; var className = compat.getClassNameForRemoting(Model); - var url = this.url; - var adapter = this.adapter; + var remotes = this.remotes + var SharedClass; + var classes; + var i = 0; + + remotes.exports[className] = Model; - assert(Model.app, 'Cannot attach Model: ' + Model.modelName - + ' to a RemoteConnector. You must first attach it to an app!'); + classes = remotes.classes(); + + for(; i < classes.length; i++) { + SharedClass = classes[i]; + if(SharedClass.name === className) { + SharedClass + .methods() + .forEach(function(remoteMethod) { + // TODO(ritch) more elegant way of ignoring a nested shared class + if(remoteMethod.name !== 'Change' + && remoteMethod.name !== 'Checkpoint') { + createProxyMethod(Model, remotes, remoteMethod); + } + }); - Model.remotes(function(err, remotes) { - var sharedClass = getSharedClass(remotes, className); - remotes.connect(url, adapter); - sharedClass - .methods() - .forEach(Model.createProxyMethod.bind(Model)); - }); + return; + } + } } -function getSharedClass(remotes, className) { - return _.find(remotes.classes(), function(sharedClass) { - return sharedClass.name === className; - }); +function createProxyMethod(Model, remotes, remoteMethod) { + var scope = remoteMethod.isStatic ? Model : Model.prototype; + var original = scope[remoteMethod.name]; + + var fn = scope[remoteMethod.name] = function remoteMethodProxy() { + var args = Array.prototype.slice.call(arguments); + var lastArgIsFunc = typeof args[args.length - 1] === 'function'; + var callback; + if(lastArgIsFunc) { + callback = args.pop(); + } + + remotes.invoke(remoteMethod.stringName, args, callback); + } + + for(var key in original) { + fn[key] = original[key]; + } + fn._delegate = true; } + function noop() {} diff --git a/lib/models/change.js b/lib/models/change.js index 333c4689f..3eab893bc 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var Model = require('../loopback').Model +var DataModel = require('./data-model') , loopback = require('../loopback') , crypto = require('crypto') , CJSON = {stringify: require('canonical-json')} @@ -44,7 +44,7 @@ var options = { * @inherits {Model} */ -var Change = module.exports = Model.extend('Change', properties, options); +var Change = module.exports = DataModel.extend('Change', properties, options); /*! * Constants @@ -271,6 +271,7 @@ Change.prototype.getModelCtor = function() { */ Change.prototype.equals = function(change) { + if(!change) return false; return change.rev === this.rev; } @@ -337,9 +338,10 @@ Change.diff = function(modelName, since, remoteChanges, callback) { var localModelIds = []; localChanges.forEach(function(localChange) { + localChange = new Change(localChange); localModelIds.push(localChange.modelId); var remoteChange = remoteChangeIndex[localChange.modelId]; - if(!localChange.equals(remoteChange)) { + if(remoteChange && !localChange.equals(remoteChange)) { if(remoteChange.isBasedOn(localChange)) { deltas.push(remoteChange); } else { diff --git a/lib/models/model.js b/lib/models/model.js index d88daa007..018ef3bfc 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -3,10 +3,13 @@ */ var loopback = require('../loopback'); var compat = require('../compat'); -var ModelBuilder = require('loopback-datasource-juggler').ModelBuilder; +var juggler = require('loopback-datasource-juggler'); +var ModelBuilder = juggler.ModelBuilder; +var DataSource = juggler.DataSource; var modeler = new ModelBuilder(); var async = require('async'); var assert = require('assert'); +var _ = require('underscore'); /** * The base class for **all models**. @@ -89,6 +92,10 @@ Model.setup = function () { var ModelCtor = this; var options = this.settings; + if(options.trackChanges) { + this._defineChangeModel(); + } + ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { fn = data; @@ -176,12 +183,12 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; - ModelCtor.once('dataSourceAttached', function() { - // enable change tracking (usually for replication) - if(options.trackChanges) { + // enable change tracking (usually for replication) + if(options.trackChanges) { + ModelCtor.once('dataSourceAttached', function() { ModelCtor.enableChangeTracking(); - } - }); + }); + } return ModelCtor; }; @@ -294,51 +301,6 @@ Model.getApp = function(callback) { } } -/** - * Get the Model's `RemoteObjects`. - * - * @callback {Function} callback - * @param {Error} err - * @param {RemoteObjects} remoteObjects - * @end - */ - -Model.remotes = function(callback) { - this.getApp(function(err, app) { - callback(null, app.remotes()); - }); -} - -/*! - * Create a proxy function for invoking remote methods. - * - * @param {SharedMethod} sharedMethod - */ - -Model.createProxyMethod = function createProxyFunction(remoteMethod) { - var Model = this; - var scope = remoteMethod.isStatic ? Model : Model.prototype; - var original = scope[remoteMethod.name]; - - var fn = scope[remoteMethod.name] = function proxy() { - var args = Array.prototype.slice.call(arguments); - var lastArgIsFunc = typeof args[args.length - 1] === 'function'; - var callback; - if(lastArgIsFunc) { - callback = args.pop(); - } - - Model.remotes(function(err, remotes) { - remotes.invoke(remoteMethod.stringName, args, callback); - }); - } - - for(var key in original) { - fn[key] = original[key]; - } - fn._delegate = true; -} - // setup the initial model Model.setup(); @@ -435,22 +397,39 @@ Model.currentCheckpoint = function(cb) { /** * Replicate changes since the given checkpoint to the given target model. * - * @param {Number} since Since this checkpoint + * @param {Number} [since] Since this checkpoint * @param {Model} targetModel Target this model class - * @options {Object} options - * @property {Object} filter Replicate models that match this filter - * @callback {Function} callback + * @param {Object} [options] + * @param {Object} [options.filter] Replicate models that match this filter + * @callback {Function} [callback] * @param {Error} err - * @param {Array} conflicts A list of changes that could not be replicated + * @param {Conflict[]} conflicts A list of changes that could not be replicated * due to conflicts. */ Model.replicate = function(since, targetModel, options, callback) { + var lastArg = arguments[arguments.length - 1]; + + if(typeof lastArg === 'function' && arguments.length > 1) { + callback = lastArg; + } + + if(typeof since === 'funciton' && since.modelName) { + since = -1; + targetModel = since; + } + var sourceModel = this; var diff; var updates; var Change = this.getChangeModel(); var TargetChange = targetModel.getChangeModel(); + var changeTrackingEnabled = Change && TargetChange; + + assert( + changeTrackingEnabled, + 'You must enable change tracking before replicating' + ); var tasks = [ getLocalChanges, @@ -586,18 +565,16 @@ Model.bulkUpdate = function(updates, callback) { /** * Get the `Change` model. * + * @throws {Error} Throws an error if the change model is not correctly setup. * @return {Change} */ Model.getChangeModel = function() { var changeModel = this.Change; - if(changeModel) return changeModel; - this.Change = changeModel = require('./change').extend(this.modelName + '-change'); + var isSetup = changeModel && changeModel.dataSource; - assert(this.dataSource, 'Cannot getChangeModel(): ' + this.modelName - + ' is not attached to a dataSource'); + assert(isSetup, 'Cannot get a setup Change model'); - changeModel.attachTo(this.dataSource); return changeModel; } @@ -628,9 +605,15 @@ Model.getSourceId = function(cb) { Model.enableChangeTracking = function() { var Model = this; - var Change = Model.getChangeModel(); + var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; + assert(this.dataSource, 'Cannot enableChangeTracking(): ' + this.modelName + + ' is not attached to a dataSource'); + + Change.attachTo(this.dataSource); + Change.getCheckpointModel().attachTo(this.dataSource); + Model.on('changed', function(obj) { Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { if(err) { @@ -666,3 +649,8 @@ Model.enableChangeTracking = function() { }); } } + +Model._defineChangeModel = function() { + var BaseChangeModel = require('./change'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change'); +} diff --git a/package.json b/package.json index b1b223e8f..4c6aeb283 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,8 @@ "karma": "~0.10.9", "karma-browserify": "~0.2.0", "karma-mocha": "~0.1.1", - "grunt-karma": "~0.6.2" + "grunt-karma": "~0.6.2", + "loopback-explorer": "~1.1.0" }, "repository": { "type": "git", diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 3fb806fb3..791b43c57 100644 --- a/test/e2e/remote-connector.e2e.js +++ b/test/e2e/remote-connector.e2e.js @@ -7,12 +7,10 @@ var assert = require('assert'); describe('RemoteConnector', function() { before(function() { // setup the remote connector - var localApp = loopback(); var ds = loopback.createDataSource({ url: 'http://localhost:3000/api', connector: loopback.Remote }); - localApp.model(TestModel); TestModel.attachTo(ds); }); diff --git a/test/e2e/replication.e2e.js b/test/e2e/replication.e2e.js index e38264a77..fc42def4c 100644 --- a/test/e2e/replication.e2e.js +++ b/test/e2e/replication.e2e.js @@ -2,61 +2,36 @@ var path = require('path'); var loopback = require('../../'); var models = require('../fixtures/e2e/models'); var TestModel = models.TestModel; -var LocalTestModel = TestModel.extend('LocalTestModel'); -var assert = require('assert'); - -describe('ReplicationModel', function () { - it('ReplicationModel.enableChangeTracking()', function (done) { - var TestReplicationModel = loopback.DataModel.extend('TestReplicationModel'); - var remote = loopback.createDataSource({ - url: 'http://localhost:3000/api', - connector: loopback.Remote - }); - var testApp = loopback(); - testApp.model(TestReplicationModel); - TestReplicationModel.attachTo(remote); - // chicken-egg condition - // getChangeModel() requires it to be attached to an app - // attaching to the app requires getChangeModel() - var Change = TestReplicationModel.getChangeModel(); - testApp.model(Change); - Change.attachTo(remote); - TestReplicationModel.enableChangeTracking(); - }); +var LocalTestModel = TestModel.extend('LocalTestModel', {}, { + trackChanges: true }); +var assert = require('assert'); -describe.skip('Replication', function() { - beforeEach(function() { +describe('Replication', function() { + before(function() { // setup the remote connector - var localApp = loopback(); var ds = loopback.createDataSource({ url: 'http://localhost:3000/api', connector: loopback.Remote }); - localApp.model(TestModel); - localApp.model(LocalTestModel); TestModel.attachTo(ds); var memory = loopback.memory(); LocalTestModel.attachTo(memory); - - // TODO(ritch) this should be internal... - LocalTestModel.getChangeModel().attachTo(memory); - - LocalTestModel.enableChangeTracking(); - - // failing because change model is not properly attached - TestModel.enableChangeTracking(); }); it('should replicate local data to the remote', function (done) { + var RANDOM = Math.random(); + LocalTestModel.create({ - foo: 'bar' - }, function() { + n: RANDOM + }, function(err, created) { LocalTestModel.replicate(0, TestModel, function() { - console.log('replicated'); - done(); + if(err) return done(err); + TestModel.findOne({n: RANDOM}, function(err, found) { + assert.equal(created.id, found.id); + done(); + }); }); }); }); - }); diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 337d61454..462d04260 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,12 +3,18 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; +var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; app.use(apiPath, loopback.rest()); + +TestModel.attachTo(loopback.memory()); +app.model(TestModel); +app.model(TestModel.getChangeModel()); + +app.use('/explorer', explorer(app, {basePath: apiPath})); + app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); -app.model(TestModel); -TestModel.attachTo(loopback.memory()); diff --git a/test/fixtures/e2e/models.js b/test/fixtures/e2e/models.js index dad14f615..e1c22d6e0 100644 --- a/test/fixtures/e2e/models.js +++ b/test/fixtures/e2e/models.js @@ -1,4 +1,6 @@ var loopback = require('../../../'); var DataModel = loopback.DataModel; -exports.TestModel = DataModel.extend('TestModel'); +exports.TestModel = DataModel.extend('TestModel', {}, { + trackChanges: true +}); diff --git a/test/model.test.js b/test/model.test.js index 8eaf76758..77069ec84 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -18,6 +18,8 @@ describe('Model', function() { 'gender': String, 'domain': String, 'email': String + }, { + trackChanges: true }); }); From 69cd6a836bb076b61b8829732d840944481b1242 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 29 Apr 2014 20:40:00 -0700 Subject: [PATCH 13/74] !fixup .replicate() argument handling --- lib/connectors/remote.js | 4 ++-- lib/models/model.js | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 1a252e94b..23f469a85 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -32,8 +32,8 @@ function RemoteConnector(settings) { this.url = this.protocol + '://' + this.host + ':' + this.port + this.root; } - // handle mixins here - this.DataAccessObject = function() {}; + // handle mixins in the define() method + var DAO = this.DataAccessObject = function() {}; } RemoteConnector.prototype.connect = function() { diff --git a/lib/models/model.js b/lib/models/model.js index 018ef3bfc..8d50976f6 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -414,11 +414,13 @@ Model.replicate = function(since, targetModel, options, callback) { callback = lastArg; } - if(typeof since === 'funciton' && since.modelName) { - since = -1; + if(typeof since === 'function' && since.modelName) { targetModel = since; + since = -1; } + options = options || {}; + var sourceModel = this; var diff; var updates; @@ -450,7 +452,7 @@ Model.replicate = function(since, targetModel, options, callback) { return new Change.Conflict(sourceChange, targetChange); }); - callback(null, conflicts); + callback && callback(null, conflicts); }); function getLocalChanges(cb) { @@ -546,7 +548,6 @@ Model.bulkUpdate = function(updates, callback) { // tasks.push(model.save.bind(model)); tasks.push(function(cb) { var model = new Model(update.data); - debugger; model.save(cb); }); break; From 918596f0353b726d4b0da7142a6b8e0aefa94b11 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 18:12:24 -0700 Subject: [PATCH 14/74] Refactor DataModel remoting - Move DataModel remoting setup into setup phase - Add a remoting type converter for DataModels - Move model tests into re-usable test utilities - Move other test utilities into new test utilities folder --- lib/models/data-model.js | 206 +++++++++++--------- test/remote-connector.test.js | 60 +++--- test/support.js | 16 -- test/util/describe.js | 19 ++ test/{model.test.js => util/model-tests.js} | 80 ++++++-- 5 files changed, 220 insertions(+), 161 deletions(-) create mode 100644 test/util/describe.js rename test/{model.test.js => util/model-tests.js} (92%) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index c39660367..6120e1aeb 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -1,7 +1,9 @@ /*! * Module Dependencies. */ + var Model = require('./model'); +var RemoteObjects = require('strong-remoting'); var DataAccess = require('loopback-datasource-juggler/lib/dao'); /** @@ -24,6 +26,22 @@ var DataAccess = require('loopback-datasource-juggler/lib/dao'); var DataModel = module.exports = Model.extend('DataModel'); +/*! + * Setup the `DataModel` constructor. + */ + +DataModel.setup = function setupDataModel() { + var DataModel = this; + var typeName = this.modelName; + + // setup a remoting type converter for this model + RemoteObjects.convert(typeName, function(val) { + return val ? new DataModel(val) : val; + }); + + DataModel.setupRemoting(); +} + /*! * Configure the remoting attributes for a given function * @param {Function} fn The function @@ -87,13 +105,6 @@ DataModel.create = function (data, callback) { throwNotAttached(this.modelName, 'create'); }; -setRemoting(DataModel.create, { - description: 'Create a new instance of the model and persist it into the data source', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'post', path: '/'} -}); - /** * Update or insert a model instance * @param {Object} data The model instance data @@ -104,16 +115,8 @@ DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { throwNotAttached(this.modelName, 'updateOrCreate'); }; -// upsert ~ remoting attributes -setRemoting(DataModel.upsert, { - description: 'Update an existing model instance or insert a new one into the data source', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'put', path: '/'} -}); - /** - * Find one record, same as `all`, limited by 1 and return object, not collection, + * Find one record, same as `find`, limited by 1 and return object, not collection, * if not found, create using data provided as second argument * * @param {Object} query - search conditions: {where: {test: 'me'}}. @@ -136,14 +139,6 @@ DataModel.exists = function exists(id, cb) { throwNotAttached(this.modelName, 'exists'); }; -// exists ~ remoting attributes -setRemoting(DataModel.exists, { - description: 'Check whether a model instance exists in the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - returns: {arg: 'exists', type: 'any'}, - http: {verb: 'get', path: '/:id/exists'} -}); - /** * Find object by id * @@ -155,15 +150,6 @@ DataModel.findById = function find(id, cb) { throwNotAttached(this.modelName, 'find'); }; -// find ~ remoting attributes -setRemoting(DataModel.findById, { - description: 'Find a model instance by id from the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - returns: {arg: 'data', type: 'any', root: true}, - http: {verb: 'get', path: '/:id'}, - rest: {after: convertNullToNotFoundError} -}); - /** * Find all instances of Model, matched by query * make sure you have marked as `index: true` fields for filter or sort @@ -186,14 +172,6 @@ DataModel.find = function find(params, cb) { throwNotAttached(this.modelName, 'find'); }; -// all ~ remoting attributes -setRemoting(DataModel.find, { - description: 'Find all instances of the model matched by filter from the data source', - accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, - returns: {arg: 'data', type: 'array', root: true}, - http: {verb: 'get', path: '/'} -}); - /** * Find one record, same as `all`, limited by 1 and return object, not collection * @@ -205,13 +183,6 @@ DataModel.findOne = function findOne(params, cb) { throwNotAttached(this.modelName, 'findOne'); }; -setRemoting(DataModel.findOne, { - description: 'Find first instance of the model matched by filter from the data source', - accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'get', path: '/findOne'} -}); - /** * Destroy all matching records * @param {Object} [where] An object that defines the criteria @@ -224,6 +195,9 @@ DataModel.destroyAll = function destroyAll(where, cb) { throwNotAttached(this.modelName, 'destroyAll'); }; +// disable remoting by default +DataModel.destroyAll.shared = false; + /** * Destroy a record by id * @param {*} id The id value @@ -236,13 +210,6 @@ DataModel.destroyById = function deleteById(id, cb) { throwNotAttached(this.modelName, 'deleteById'); }; -// deleteById ~ remoting attributes -setRemoting(DataModel.deleteById, { - description: 'Delete a model instance by id from the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - http: {verb: 'del', path: '/:id'} -}); - /** * Return count of matched records * @@ -254,14 +221,6 @@ DataModel.count = function (where, cb) { throwNotAttached(this.modelName, 'count'); }; -// count ~ remoting attributes -setRemoting(DataModel.count, { - description: 'Count instances of the model matched by where from the data source', - accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, - returns: {arg: 'count', type: 'number'}, - http: {verb: 'get', path: '/count'} -}); - /** * Save instance. When instance haven't id, create method called instead. * Triggers: validate, save, update | create @@ -270,23 +229,9 @@ setRemoting(DataModel.count, { */ DataModel.prototype.save = function (options, callback) { - var inst = this; - var DataModel = inst.constructor; - - if(typeof options === 'function') { - callback = options; - options = {}; - } - - // delegates directly to DataAccess - DataAccess.prototype.save.call(this, options, function(err, data) { - if(err) return callback(data); - var saved = new DataModel(data); - inst.setId(saved.getId()); - callback(null, data); - }); + throwNotAttached(this.constructor.modelName, 'save'); }; - +DataModel.prototype.save._delegate = true; /** * Determine if the data model is new. @@ -309,6 +254,8 @@ DataModel.prototype.destroy = function (cb) { throwNotAttached(this.constructor.modelName, 'destroy'); }; +DataModel.prototype.destroy._delegate = true; + /** * Update single attribute * @@ -337,14 +284,6 @@ DataModel.prototype.updateAttributes = function updateAttributes(data, cb) { throwNotAttached(this.modelName, 'updateAttributes'); }; -// updateAttributes ~ remoting attributes -setRemoting(DataModel.prototype.updateAttributes, { - description: 'Update attributes for a model instance and persist it into the data source', - accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'put', path: '/'} -}); - /** * Reload object from persistence * @@ -357,7 +296,7 @@ DataModel.prototype.reload = function reload(callback) { }; /** - * Set the corret `id` property for the `DataModel`. If a `Connector` defines + * Set the correct `id` property for the `DataModel`. If a `Connector` defines * a `setId` method it will be used. Otherwise the default lookup is used. You * should override this method to handle complex ids. * @@ -370,6 +309,12 @@ DataModel.prototype.setId = function(val) { this[this.getIdName()] = val; } +/** + * Get the `id` value for the `DataModel`. + * + * @returns {*} The `id` value + */ + DataModel.prototype.getId = function() { var data = this.toObject(); if(!data) return; @@ -378,6 +323,8 @@ DataModel.prototype.getId = function() { /** * Get the id property name of the constructor. + * + * @returns {String} The `id` property name */ DataModel.prototype.getIdName = function() { @@ -385,7 +332,9 @@ DataModel.prototype.getIdName = function() { } /** - * Get the id property name + * Get the id property name of the constructor. + * + * @returns {String} The `id` property name */ DataModel.getIdName = function() { @@ -398,3 +347,82 @@ DataModel.getIdName = function() { return 'id'; } } + +DataModel.setupRemoting = function() { + var DataModel = this; + var typeName = DataModel.modelName; + + setRemoting(DataModel.create, { + description: 'Create a new instance of the model and persist it into the data source', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'post', path: '/'} + }); + + setRemoting(DataModel.upsert, { + description: 'Update an existing model instance or insert a new one into the data source', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'put', path: '/'} + }); + + setRemoting(DataModel.exists, { + description: 'Check whether a model instance exists in the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + returns: {arg: 'exists', type: 'boolean'}, + http: {verb: 'get', path: '/:id/exists'} + }); + + setRemoting(DataModel.findById, { + description: 'Find a model instance by id from the data source', + accepts: { + arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'} + }, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'get', path: '/:id'}, + rest: {after: convertNullToNotFoundError} + }); + + setRemoting(DataModel.find, { + description: 'Find all instances of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: [typeName], root: true}, + http: {verb: 'get', path: '/'} + }); + + setRemoting(DataModel.findOne, { + description: 'Find first instance of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'get', path: '/findOne'} + }); + + setRemoting(DataModel.destroyAll, { + description: 'Delete all matching records', + accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, + http: {verb: 'delete', path: '/'} + }); + + setRemoting(DataModel.deleteById, { + description: 'Delete a model instance by id from the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + http: {verb: 'del', path: '/:id'} + }); + + setRemoting(DataModel.count, { + description: 'Count instances of the model matched by where from the data source', + accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + returns: {arg: 'count', type: 'number'}, + http: {verb: 'get', path: '/count'} + }); + + setRemoting(DataModel.prototype.updateAttributes, { + description: 'Update attributes for a model instance and persist it into the data source', + accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'put', path: '/'} + }); +} + +DataModel.setup(); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 2a55c02ce..10ac01e73 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -1,43 +1,29 @@ var loopback = require('../'); +var defineModelTestsWithDataSource = require('./util/model-tests'); describe('RemoteConnector', function() { - beforeEach(function(done) { - var LocalModel = this.LocalModel = loopback.DataModel.extend('LocalModel'); - var RemoteModel = loopback.DataModel.extend('LocalModel'); - var localApp = loopback(); - var remoteApp = loopback(); - localApp.model(LocalModel); - remoteApp.model(RemoteModel); - remoteApp.use(loopback.rest()); - RemoteModel.attachTo(loopback.memory()); - remoteApp.listen(0, function() { - var ds = loopback.createDataSource({ - host: remoteApp.get('host'), - port: remoteApp.get('port'), - connector: loopback.Remote - }); - - LocalModel.attachTo(ds); - done(); - }); - }); + var remoteApp; + var remote; - it('should alow methods to be called remotely', function (done) { - var data = {foo: 'bar'}; - this.LocalModel.create(data, function(err, result) { - if(err) return done(err); - expect(result).to.deep.equal({id: 1, foo: 'bar'}); - done(); - }); - }); - - it('should alow instance methods to be called remotely', function (done) { - var data = {foo: 'bar'}; - var m = new this.LocalModel(data); - m.save(function(err, result) { - if(err) return done(err); - expect(result).to.deep.equal({id: 2, foo: 'bar'}); - done(); - }); + defineModelTestsWithDataSource({ + beforeEach: function(done) { + var test = this; + remoteApp = loopback(); + remoteApp.use(loopback.logger('dev')); + remoteApp.use(loopback.rest()); + remoteApp.listen(0, function() { + test.dataSource = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + done(); + }); + }, + onDefine: function(Model) { + var RemoteModel = Model.extend(Model.modelName); + RemoteModel.attachTo(loopback.memory()); + remoteApp.model(RemoteModel); + } }); }); diff --git a/test/support.js b/test/support.js index 6737119a6..33dcc8555 100644 --- a/test/support.js +++ b/test/support.js @@ -49,19 +49,3 @@ assert.isFunc = function (obj, name) { assert(obj, 'cannot assert function ' + name + ' on object that doesnt exist'); assert(typeof obj[name] === 'function', name + ' is not a function'); } - -describe.onServer = function describeOnServer(name, fn) { - if (loopback.isServer) { - describe(name, fn); - } else { - describe.skip(name, fn); - } -}; - -describe.inBrowser = function describeInBrowser(name, fn) { - if (loopback.isBrowser) { - describe(name, fn); - } else { - describe.skip(name, fn); - } -}; \ No newline at end of file diff --git a/test/util/describe.js b/test/util/describe.js new file mode 100644 index 000000000..db7121131 --- /dev/null +++ b/test/util/describe.js @@ -0,0 +1,19 @@ +var loopback = require('../../'); + +module.exports = describe; + +describe.onServer = function describeOnServer(name, fn) { + if (loopback.isServer) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; + +describe.inBrowser = function describeInBrowser(name, fn) { + if (loopback.isBrowser) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; diff --git a/test/model.test.js b/test/util/model-tests.js similarity index 92% rename from test/model.test.js rename to test/util/model-tests.js index 77069ec84..b17466d59 100644 --- a/test/model.test.js +++ b/test/util/model-tests.js @@ -1,16 +1,53 @@ +/* + +Before merging notes: + + - must fix the ".skip" tests below before merging + - somehow need to handle callback values that are model typed + - findById isn't getting an id... perhaps a remoting bug? + + eg. + + User.create({name: 'joe'}, function(err, user) { + assert(user instanceof User); // ...! + }); + +*/ + var async = require('async'); -require('./support'); -var loopback = require('../'); +var describe = require('./describe'); +var loopback = require('../../'); var ACL = loopback.ACL; var Change = loopback.Change; +var DataModel = loopback.DataModel; + +module.exports = function defineModelTestsWithDataSource(options) { + var User, dataSource; + + if(options.beforeEach) { + beforeEach(options.beforeEach); + } + + beforeEach(function() { + var test = this; + + // setup a model / datasource + dataSource = this.dataSource || loopback.createDataSource(options.dataSource); -describe('Model', function() { + var extend = DataModel.extend; - var User, memory; + // create model hook + DataModel.extend = function() { + var extendedModel = extend.apply(DataModel, arguments); - beforeEach(function () { - memory = loopback.createDataSource({connector: loopback.Memory}); - User = memory.createModel('user', { + if(options.onDefine) { + options.onDefine.call(test, extendedModel) + } + + return extendedModel; + } + + User = DataModel.extend('user', { 'first': String, 'last': String, 'age': Number, @@ -21,6 +58,10 @@ describe('Model', function() { }, { trackChanges: true }); + + User.attachTo(dataSource); + + }); describe('Model.validatesPresenceOf(properties...)', function() { @@ -77,13 +118,13 @@ describe('Model', function() { }); }); - describe('Model.validatesUniquenessOf(property, options)', function() { + describe.skip('Model.validatesUniquenessOf(property, options)', function() { it("Ensure the value for `property` is unique", function(done) { User.validatesUniquenessOf('email', {message: 'email is not unique'}); var joe = new User({email: 'joe@joe.com'}); var joe2 = new User({email: 'joe@joe.com'}); - + joe.save(function () { joe2.save(function (err) { assert(err, 'should get a validation error'); @@ -115,19 +156,19 @@ describe('Model', function() { }); }); - describe('Model.attachTo(dataSource)', function() { + describe.skip('Model.attachTo(dataSource)', function() { it("Attach a model to a [DataSource](#data-source)", function() { var MyModel = loopback.createModel('my-model', {name: String}); assert(MyModel.find === undefined, 'should not have data access methods'); - MyModel.attachTo(memory); + MyModel.attachTo(dataSource); assert(typeof MyModel.find === 'function', 'should have data access methods after attaching to a data source'); }); }); - describe('Model.create([data], [callback])', function() { + describe.skip('Model.create([data], [callback])', function() { it("Create an instance of Model with given data and save to the attached data source", function(done) { User.create({first: 'Joe', last: 'Bob'}, function(err, user) { assert(user instanceof User); @@ -148,7 +189,7 @@ describe('Model', function() { }); }); - describe('model.updateAttributes(data, [callback])', function() { + describe.skip('model.updateAttributes(data, [callback])', function() { it("Save specified attributes to the attached data source", function(done) { User.create({first: 'joe', age: 100}, function (err, user) { assert(!err); @@ -186,6 +227,7 @@ describe('Model', function() { describe('model.destroy([callback])', function() { it("Remove a model from the attached data source", function(done) { User.create({first: 'joe', last: 'bob'}, function (err, user) { + console.log(User.findById.accepts); User.findById(user.id, function (err, foundUser) { assert.equal(user.id, foundUser.id); foundUser.destroy(function () { @@ -468,8 +510,8 @@ describe('Model', function() { describe('Model.hasMany(Model)', function() { it("Define a one to many relationship", function(done) { - var Book = memory.createModel('book', {title: String, author: String}); - var Chapter = memory.createModel('chapter', {title: String}); + var Book = dataSource.createModel('book', {title: String, author: String}); + var Chapter = dataSource.createModel('chapter', {title: String}); // by referencing model Book.hasMany(Chapter); @@ -672,7 +714,7 @@ describe('Model', function() { describe('Replication / Change APIs', function() { beforeEach(function(done) { var test = this; - this.dataSource = loopback.createDataSource({connector: loopback.Memory}); + this.dataSource = loopback.createDataSource(options.dataSource); var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { trackChanges: true }); @@ -705,7 +747,7 @@ describe('Model', function() { }); }); - describe('Model.replicate(since, targetModel, options, callback)', function() { + describe.skip('Model.replicate(since, targetModel, options, callback)', function() { it('Replicate data using the target model', function (done) { var test = this; var options = {}; @@ -743,11 +785,11 @@ describe('Model', function() { describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { - var Model = require('../').Model; + var Model = require('../../').Model; var acl = ACL.extend('acl'); Model._ACL(null); // Reset the ACL class for the base model var model = Model._ACL(); assert.equal(model, acl); }); }); -}); +} From 1c7527ae3dd10117caf57859c2af15b67ab4327d Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 18:12:50 -0700 Subject: [PATCH 15/74] Add missing test/model file --- test/model.test.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 test/model.test.js diff --git a/test/model.test.js b/test/model.test.js new file mode 100644 index 000000000..564044ed0 --- /dev/null +++ b/test/model.test.js @@ -0,0 +1,13 @@ +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); + +describe('Model', function() { + defineModelTestsWithDataSource({ + dataSource: { + connector: loopback.Memory + } + }); +}); From 012f880078ad15588a7a67735241cc2848abe4e1 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 20:04:06 -0700 Subject: [PATCH 16/74] !fixup RemoteConnector tests --- lib/models/data-model.js | 6 +- lib/models/model.js | 2 +- test/model.test.js | 509 ++++++++++++++++++++++++++++++++++ test/remote-connector.test.js | 5 +- test/support.js | 1 + test/util/model-tests.js | 504 +-------------------------------- 6 files changed, 530 insertions(+), 497 deletions(-) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 6120e1aeb..d950f7c88 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -31,6 +31,9 @@ var DataModel = module.exports = Model.extend('DataModel'); */ DataModel.setup = function setupDataModel() { + // call Model.setup first + Model.setup.call(this); + var DataModel = this; var typeName = this.modelName; @@ -406,7 +409,8 @@ DataModel.setupRemoting = function() { setRemoting(DataModel.deleteById, { description: 'Delete a model instance by id from the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'}}, http: {verb: 'del', path: '/:id'} }); diff --git a/lib/models/model.js b/lib/models/model.js index 8d50976f6..d28cb7868 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -182,7 +182,7 @@ Model.setup = function () { ]; ModelCtor.sharedCtor.returns = {root: true}; - + // enable change tracking (usually for replication) if(options.trackChanges) { ModelCtor.once('dataSourceAttached', function() { diff --git a/test/model.test.js b/test/model.test.js index 564044ed0..4abb4b898 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -3,6 +3,7 @@ var loopback = require('../'); var ACL = loopback.ACL; var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); +var DataModel = loopback.DataModel; describe('Model', function() { defineModelTestsWithDataSource({ @@ -11,3 +12,511 @@ describe('Model', function() { } }); }); + +describe.onServer('Remote Methods', function(){ + + var User; + var dataSource; + var app; + + beforeEach(function () { + User = DataModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true + }); + + dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + + User.attachTo(dataSource); + + User.login = function (username, password, fn) { + if(username === 'foo' && password === 'bar') { + fn(null, 123); + } else { + throw new Error('bad username and password!'); + } + } + + loopback.remoteMethod( + User.login, + { + accepts: [ + {arg: 'username', type: 'string', required: true}, + {arg: 'password', type: 'string', required: true} + ], + returns: {arg: 'sessionId', type: 'any', root: true}, + http: {path: '/sign-in', verb: 'get'} + } + ); + + app = loopback(); + app.use(loopback.rest()); + app.model(User); + }); + + describe('Example Remote Method', function () { + it('Call the method using HTTP / REST', function(done) { + request(app) + .get('/users/sign-in?username=foo&password=bar') + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res){ + if(err) return done(err); + assert.equal(res.body, 123); + done(); + }); + }); + + it('Converts null result of findById to 404 Not Found', function(done) { + request(app) + .get('/users/not-found') + .expect(404) + .end(done); + }); + }); + + describe('Model.beforeRemote(name, fn)', function(){ + it('Run a function before a remote method is called by a client', function(done) { + var hookCalled = false; + + User.beforeRemote('create', function(ctx, user, next) { + hookCalled = true; + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(hookCalled, 'hook wasnt called'); + done(); + }); + }); + }); + + describe('Model.afterRemote(name, fn)', function(){ + it('Run a function after a remote method is called by a client', function(done) { + var beforeCalled = false; + var afterCalled = false; + + User.beforeRemote('create', function(ctx, user, next) { + assert(!afterCalled); + beforeCalled = true; + next(); + }); + User.afterRemote('create', function(ctx, user, next) { + assert(beforeCalled); + afterCalled = true; + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(beforeCalled, 'before hook was not called'); + assert(afterCalled, 'after hook was not called'); + done(); + }); + }); + }); + + describe('Remote Method invoking context', function () { + // describe('ctx.user', function() { + // it("The remote user model calling the method remotely", function(done) { + // done(new Error('test not implemented')); + // }); + // }); + + describe('ctx.req', function() { + it("The express ServerRequest object", function(done) { + var hookCalled = false; + + User.beforeRemote('create', function(ctx, user, next) { + hookCalled = true; + assert(ctx.req); + assert(ctx.req.url); + assert(ctx.req.method); + assert(ctx.res); + assert(ctx.res.write); + assert(ctx.res.end); + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(hookCalled); + done(); + }); + }); + }); + + describe('ctx.res', function() { + it("The express ServerResponse object", function(done) { + var hookCalled = false; + + User.beforeRemote('create', function(ctx, user, next) { + hookCalled = true; + assert(ctx.req); + assert(ctx.req.url); + assert(ctx.req.method); + assert(ctx.res); + assert(ctx.res.write); + assert(ctx.res.end); + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(hookCalled); + done(); + }); + }); + }); + }) + + describe('in compat mode', function() { + before(function() { + loopback.compat.usePluralNamesForRemoting = true; + }); + after(function() { + loopback.compat.usePluralNamesForRemoting = false; + }); + + it('correctly install before/after hooks', function(done) { + var hooksCalled = []; + + User.beforeRemote('**', function(ctx, user, next) { + hooksCalled.push('beforeRemote'); + next(); + }); + + User.afterRemote('**', function(ctx, user, next) { + hooksCalled.push('afterRemote'); + next(); + }); + + request(app).get('/users') + .expect(200, function(err, res) { + if (err) return done(err); + expect(hooksCalled, 'hooks called') + .to.eql(['beforeRemote', 'afterRemote']); + done(); + }); + }); + }); + + describe('Model.hasMany(Model)', function() { + it("Define a one to many relationship", function(done) { + var Book = dataSource.createModel('book', {title: String, author: String}); + var Chapter = dataSource.createModel('chapter', {title: String}); + + // by referencing model + Book.hasMany(Chapter); + + Book.create({title: 'Into the Wild', author: 'Jon Krakauer'}, function(err, book) { + // using 'chapters' scope for build: + var c = book.chapters.build({title: 'Chapter 1'}); + book.chapters.create({title: 'Chapter 2'}, function () { + c.save(function () { + Chapter.count({bookId: book.id}, function (err, count) { + assert.equal(count, 2); + book.chapters({where: {title: 'Chapter 1'}}, function(err, chapters) { + assert.equal(chapters.length, 1); + assert.equal(chapters[0].title, 'Chapter 1'); + done(); + }); + }); + }); + }); + }); + }); + }); + + describe('Model.properties', function(){ + it('Normalized properties passed in originally by loopback.createModel()', function() { + var props = { + s: String, + n: {type: 'Number'}, + o: {type: 'String', min: 10, max: 100}, + d: Date, + g: loopback.GeoPoint + }; + + var MyModel = loopback.createModel('foo', props); + + Object.keys(MyModel.definition.properties).forEach(function (key) { + var p = MyModel.definition.properties[key]; + var o = MyModel.definition.properties[key]; + assert(p); + assert(o); + assert(typeof p.type === 'function'); + + if(typeof o === 'function') { + // the normalized property + // should match the given property + assert( + p.type.name === o.name + || + p.type.name === o + ) + } + }); + }); + }); + + describe('Model.extend()', function(){ + it('Create a new model by extending an existing model', function() { + var User = loopback.Model.extend('test-user', { + email: String + }); + + User.foo = function () { + return 'bar'; + } + + User.prototype.bar = function () { + return 'foo'; + } + + var MyUser = User.extend('my-user', { + a: String, + b: String + }); + + assert.equal(MyUser.prototype.bar, User.prototype.bar); + assert.equal(MyUser.foo, User.foo); + + var user = new MyUser({ + email: 'foo@bar.com', + a: 'foo', + b: 'bar' + }); + + assert.equal(user.email, 'foo@bar.com'); + assert.equal(user.a, 'foo'); + assert.equal(user.b, 'bar'); + }); + }); + + describe('Model.extend() events', function() { + it('create isolated emitters for subclasses', function() { + var User1 = loopback.createModel('User1', { + 'first': String, + 'last': String + }); + + var User2 = loopback.createModel('User2', { + 'name': String + }); + + var user1Triggered = false; + User1.once('x', function(event) { + user1Triggered = true; + }); + + + var user2Triggered = false; + User2.once('x', function(event) { + user2Triggered = true; + }); + + assert(User1.once !== User2.once); + assert(User1.once !== loopback.Model.once); + + User1.emit('x', User1); + + assert(user1Triggered); + assert(!user2Triggered); + }); + + }); + + describe('Model.checkAccessTypeForMethod(remoteMethod)', function () { + shouldReturn('create', ACL.WRITE); + shouldReturn('updateOrCreate', ACL.WRITE); + shouldReturn('upsert', ACL.WRITE); + shouldReturn('exists', ACL.READ); + shouldReturn('findById', ACL.READ); + shouldReturn('find', ACL.READ); + shouldReturn('findOne', ACL.READ); + shouldReturn('destroyById', ACL.WRITE); + shouldReturn('deleteById', ACL.WRITE); + shouldReturn('removeById', ACL.WRITE); + shouldReturn('count', ACL.READ); + shouldReturn('unkown-model-method', ACL.EXECUTE); + + function shouldReturn(methodName, expectedAccessType) { + describe(methodName, function () { + it('should return ' + expectedAccessType, function() { + var remoteMethod = {name: methodName}; + assert.equal( + User._getAccessTypeForMethod(remoteMethod), + expectedAccessType + ); + }); + }); + } + }); + + describe('Model.getChangeModel()', function() { + it('Get the Change Model', function () { + var UserChange = User.getChangeModel(); + var change = new UserChange(); + assert(change instanceof Change); + }); + }); + + describe('Model.getSourceId(callback)', function() { + it('Get the Source Id', function (done) { + User.getSourceId(function(err, id) { + assert.equal('memory-user', id); + done(); + }); + }); + }); + + describe('Model.checkpoint(callback)', function() { + it('Create a checkpoint', function (done) { + var Checkpoint = User.getChangeModel().getCheckpointModel(); + var tasks = [ + getCurrentCheckpoint, + checkpoint + ]; + var result; + var current; + + async.parallel(tasks, function(err) { + if(err) return done(err); + + assert.equal(result, current + 1); + done(); + }); + + function getCurrentCheckpoint(cb) { + Checkpoint.current(function(err, cp) { + current = cp; + cb(err); + }); + } + + function checkpoint(cb) { + User.checkpoint(function(err, cp) { + result = cp.id; + cb(err); + }); + } + }); + }); + + describe('Replication / Change APIs', function() { + beforeEach(function(done) { + var test = this; + this.dataSource = dataSource; + var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { + trackChanges: true + }); + var TargetModel = this.TargetModel = this.dataSource.createModel('TargetModel', {}, { + trackChanges: true + }); + + var createOne = SourceModel.create.bind(SourceModel, { + name: 'baz' + }); + + async.parallel([ + createOne, + function(cb) { + SourceModel.currentCheckpoint(function(err, id) { + if(err) return cb(err); + test.startingCheckpoint = id; + cb(); + }); + } + ], process.nextTick.bind(process, done)); + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }); + }); + + describe.skip('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + }); + }); + }); + + describe('Model._getACLModel()', function() { + it('should return the subclass of ACL', function() { + var Model = require('../').Model; + var acl = ACL.extend('acl'); + Model._ACL(null); // Reset the ACL class for the base model + var model = Model._ACL(); + assert.equal(model, acl); + }); + }); +}); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 10ac01e73..9e6f875de 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -9,7 +9,6 @@ describe('RemoteConnector', function() { beforeEach: function(done) { var test = this; remoteApp = loopback(); - remoteApp.use(loopback.logger('dev')); remoteApp.use(loopback.rest()); remoteApp.listen(0, function() { test.dataSource = loopback.createDataSource({ @@ -22,7 +21,9 @@ describe('RemoteConnector', function() { }, onDefine: function(Model) { var RemoteModel = Model.extend(Model.modelName); - RemoteModel.attachTo(loopback.memory()); + RemoteModel.attachTo(loopback.createDataSource({ + connector: loopback.Memory + })); remoteApp.model(RemoteModel); } }); diff --git a/test/support.js b/test/support.js index 33dcc8555..81d420e1f 100644 --- a/test/support.js +++ b/test/support.js @@ -10,6 +10,7 @@ GeoPoint = loopback.GeoPoint; app = null; TaskEmitter = require('strong-task-emitter'); request = require('supertest'); +var RemoteObjects = require('strong-remoting'); // Speed up the password hashing algorithm // for tests using the built-in User model diff --git a/test/util/model-tests.js b/test/util/model-tests.js index b17466d59..302589c1a 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -3,8 +3,8 @@ Before merging notes: - must fix the ".skip" tests below before merging - - somehow need to handle callback values that are model typed - - findById isn't getting an id... perhaps a remoting bug? + [x] somehow need to handle callback values that are model typed + [x] findById isn't getting an id... perhaps a remoting bug? eg. @@ -20,8 +20,12 @@ var loopback = require('../../'); var ACL = loopback.ACL; var Change = loopback.Change; var DataModel = loopback.DataModel; +var RemoteObjects = require('strong-remoting'); module.exports = function defineModelTestsWithDataSource(options) { + +describe('Model Tests', function() { + var User, dataSource; if(options.beforeEach) { @@ -41,7 +45,7 @@ module.exports = function defineModelTestsWithDataSource(options) { var extendedModel = extend.apply(DataModel, arguments); if(options.onDefine) { - options.onDefine.call(test, extendedModel) + options.onDefine.call(test, extendedModel); } return extendedModel; @@ -60,8 +64,6 @@ module.exports = function defineModelTestsWithDataSource(options) { }); User.attachTo(dataSource); - - }); describe('Model.validatesPresenceOf(properties...)', function() { @@ -147,7 +149,7 @@ module.exports = function defineModelTestsWithDataSource(options) { it('Asynchronously validate the model', function(done) { User.validatesNumericalityOf('age', {int: true}); - var user = new User({first: 'joe', age: 'flarg'}) + var user = new User({first: 'joe', age: 'flarg'}); user.isValid(function (valid) { assert(valid === false); assert(user.errors.age, 'model should have age error'); @@ -227,12 +229,10 @@ module.exports = function defineModelTestsWithDataSource(options) { describe('model.destroy([callback])', function() { it("Remove a model from the attached data source", function(done) { User.create({first: 'joe', last: 'bob'}, function (err, user) { - console.log(User.findById.accepts); User.findById(user.id, function (err, foundUser) { assert.equal(user.id, foundUser.id); foundUser.destroy(function () { User.findById(user.id, function (err, notFound) { - assert(!err); assert.equal(notFound, null); done(); }); @@ -247,9 +247,8 @@ module.exports = function defineModelTestsWithDataSource(options) { User.create({first: 'joe', last: 'bob'}, function (err, user) { User.deleteById(user.id, function (err) { User.findById(user.id, function (err, notFound) { - assert(!err); - assert.equal(notFound, null); - done(); + assert.equal(notFound, null); + done(); }); }); }); @@ -308,488 +307,7 @@ module.exports = function defineModelTestsWithDataSource(options) { }); }); - describe.onServer('Remote Methods', function(){ - - beforeEach(function () { - User.login = function (username, password, fn) { - if(username === 'foo' && password === 'bar') { - fn(null, 123); - } else { - throw new Error('bad username and password!'); - } - } - - loopback.remoteMethod( - User.login, - { - accepts: [ - {arg: 'username', type: 'string', required: true}, - {arg: 'password', type: 'string', required: true} - ], - returns: {arg: 'sessionId', type: 'any', root: true}, - http: {path: '/sign-in', verb: 'get'} - } - ); - - app.use(loopback.rest()); - app.model(User); - }); - - describe('Example Remote Method', function () { - it('Call the method using HTTP / REST', function(done) { - request(app) - .get('/users/sign-in?username=foo&password=bar') - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res){ - if(err) return done(err); - assert.equal(res.body, 123); - done(); - }); - }); +}); - it('Converts null result of findById to 404 Not Found', function(done) { - request(app) - .get('/users/not-found') - .expect(404) - .end(done); - }); - }); - - describe('Model.beforeRemote(name, fn)', function(){ - it('Run a function before a remote method is called by a client', function(done) { - var hookCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - hookCalled = true; - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(hookCalled, 'hook wasnt called'); - done(); - }); - }); - }); - - describe('Model.afterRemote(name, fn)', function(){ - it('Run a function after a remote method is called by a client', function(done) { - var beforeCalled = false; - var afterCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - assert(!afterCalled); - beforeCalled = true; - next(); - }); - User.afterRemote('create', function(ctx, user, next) { - assert(beforeCalled); - afterCalled = true; - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(beforeCalled, 'before hook was not called'); - assert(afterCalled, 'after hook was not called'); - done(); - }); - }); - }); - - describe('Remote Method invoking context', function () { - // describe('ctx.user', function() { - // it("The remote user model calling the method remotely", function(done) { - // done(new Error('test not implemented')); - // }); - // }); - - describe('ctx.req', function() { - it("The express ServerRequest object", function(done) { - var hookCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - hookCalled = true; - assert(ctx.req); - assert(ctx.req.url); - assert(ctx.req.method); - assert(ctx.res); - assert(ctx.res.write); - assert(ctx.res.end); - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(hookCalled); - done(); - }); - }); - }); - describe('ctx.res', function() { - it("The express ServerResponse object", function(done) { - var hookCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - hookCalled = true; - assert(ctx.req); - assert(ctx.req.url); - assert(ctx.req.method); - assert(ctx.res); - assert(ctx.res.write); - assert(ctx.res.end); - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(hookCalled); - done(); - }); - }); - }); - }) - - describe('in compat mode', function() { - before(function() { - loopback.compat.usePluralNamesForRemoting = true; - }); - after(function() { - loopback.compat.usePluralNamesForRemoting = false; - }); - - it('correctly install before/after hooks', function(done) { - var hooksCalled = []; - - User.beforeRemote('**', function(ctx, user, next) { - hooksCalled.push('beforeRemote'); - next(); - }); - - User.afterRemote('**', function(ctx, user, next) { - hooksCalled.push('afterRemote'); - next(); - }); - - request(app).get('/users') - .expect(200, function(err, res) { - if (err) return done(err); - expect(hooksCalled, 'hooks called') - .to.eql(['beforeRemote', 'afterRemote']); - done(); - }); - }); - }); - }); - - describe('Model.hasMany(Model)', function() { - it("Define a one to many relationship", function(done) { - var Book = dataSource.createModel('book', {title: String, author: String}); - var Chapter = dataSource.createModel('chapter', {title: String}); - - // by referencing model - Book.hasMany(Chapter); - - Book.create({title: 'Into the Wild', author: 'Jon Krakauer'}, function(err, book) { - // using 'chapters' scope for build: - var c = book.chapters.build({title: 'Chapter 1'}); - book.chapters.create({title: 'Chapter 2'}, function () { - c.save(function () { - Chapter.count({bookId: book.id}, function (err, count) { - assert.equal(count, 2); - book.chapters({where: {title: 'Chapter 1'}}, function(err, chapters) { - assert.equal(chapters.length, 1); - assert.equal(chapters[0].title, 'Chapter 1'); - done(); - }); - }); - }); - }); - }); - }); - }); - - describe('Model.properties', function(){ - it('Normalized properties passed in originally by loopback.createModel()', function() { - var props = { - s: String, - n: {type: 'Number'}, - o: {type: 'String', min: 10, max: 100}, - d: Date, - g: loopback.GeoPoint - }; - - var MyModel = loopback.createModel('foo', props); - - Object.keys(MyModel.definition.properties).forEach(function (key) { - var p = MyModel.definition.properties[key]; - var o = MyModel.definition.properties[key]; - assert(p); - assert(o); - assert(typeof p.type === 'function'); - - if(typeof o === 'function') { - // the normalized property - // should match the given property - assert( - p.type.name === o.name - || - p.type.name === o - ) - } - }); - }); - }); - - describe('Model.extend()', function(){ - it('Create a new model by extending an existing model', function() { - var User = loopback.Model.extend('test-user', { - email: String - }); - - User.foo = function () { - return 'bar'; - } - - User.prototype.bar = function () { - return 'foo'; - } - - var MyUser = User.extend('my-user', { - a: String, - b: String - }); - - assert.equal(MyUser.prototype.bar, User.prototype.bar); - assert.equal(MyUser.foo, User.foo); - - var user = new MyUser({ - email: 'foo@bar.com', - a: 'foo', - b: 'bar' - }); - - assert.equal(user.email, 'foo@bar.com'); - assert.equal(user.a, 'foo'); - assert.equal(user.b, 'bar'); - }); - }); - - describe('Model.extend() events', function() { - it('create isolated emitters for subclasses', function() { - var User1 = loopback.createModel('User1', { - 'first': String, - 'last': String - }); - - var User2 = loopback.createModel('User2', { - 'name': String - }); - - var user1Triggered = false; - User1.once('x', function(event) { - user1Triggered = true; - }); - - - var user2Triggered = false; - User2.once('x', function(event) { - user2Triggered = true; - }); - - assert(User1.once !== User2.once); - assert(User1.once !== loopback.Model.once); - - User1.emit('x', User1); - - assert(user1Triggered); - assert(!user2Triggered); - }); - - }); - - describe('Model.checkAccessTypeForMethod(remoteMethod)', function () { - shouldReturn('create', ACL.WRITE); - shouldReturn('updateOrCreate', ACL.WRITE); - shouldReturn('upsert', ACL.WRITE); - shouldReturn('exists', ACL.READ); - shouldReturn('findById', ACL.READ); - shouldReturn('find', ACL.READ); - shouldReturn('findOne', ACL.READ); - shouldReturn('destroyById', ACL.WRITE); - shouldReturn('deleteById', ACL.WRITE); - shouldReturn('removeById', ACL.WRITE); - shouldReturn('count', ACL.READ); - shouldReturn('unkown-model-method', ACL.EXECUTE); - - function shouldReturn(methodName, expectedAccessType) { - describe(methodName, function () { - it('should return ' + expectedAccessType, function() { - var remoteMethod = {name: methodName}; - assert.equal( - User._getAccessTypeForMethod(remoteMethod), - expectedAccessType - ); - }); - }); - } - }); - - describe('Model.getChangeModel()', function() { - it('Get the Change Model', function () { - var UserChange = User.getChangeModel(); - var change = new UserChange(); - assert(change instanceof Change); - }); - }); - - describe('Model.getSourceId(callback)', function() { - it('Get the Source Id', function (done) { - User.getSourceId(function(err, id) { - assert.equal('memory-user', id); - done(); - }); - }); - }); - - describe('Model.checkpoint(callback)', function() { - it('Create a checkpoint', function (done) { - var Checkpoint = User.getChangeModel().getCheckpointModel(); - var tasks = [ - getCurrentCheckpoint, - checkpoint - ]; - var result; - var current; - - async.parallel(tasks, function(err) { - if(err) return done(err); - - assert.equal(result, current + 1); - done(); - }); - - function getCurrentCheckpoint(cb) { - Checkpoint.current(function(err, cp) { - current = cp; - cb(err); - }); - } - - function checkpoint(cb) { - User.checkpoint(function(err, cp) { - result = cp.id; - cb(err); - }); - } - }); - }); - - describe('Replication / Change APIs', function() { - beforeEach(function(done) { - var test = this; - this.dataSource = loopback.createDataSource(options.dataSource); - var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { - trackChanges: true - }); - var TargetModel = this.TargetModel = this.dataSource.createModel('TargetModel', {}, { - trackChanges: true - }); - - var createOne = SourceModel.create.bind(SourceModel, { - name: 'baz' - }); - - async.parallel([ - createOne, - function(cb) { - SourceModel.currentCheckpoint(function(err, id) { - if(err) return cb(err); - test.startingCheckpoint = id; - cb(); - }); - } - ], process.nextTick.bind(process, done)); - }); - - describe('Model.changes(since, filter, callback)', function() { - it('Get changes since the given checkpoint', function (done) { - this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { - assert.equal(changes.length, 1); - done(); - }); - }); - }); - - describe.skip('Model.replicate(since, targetModel, options, callback)', function() { - it('Replicate data using the target model', function (done) { - var test = this; - var options = {}; - var sourceData; - var targetData; - - this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, - options, function(err, conflicts) { - assert(conflicts.length === 0); - async.parallel([ - function(cb) { - test.SourceModel.find(function(err, result) { - if(err) return cb(err); - sourceData = result; - cb(); - }); - }, - function(cb) { - test.TargetModel.find(function(err, result) { - if(err) return cb(err); - targetData = result; - cb(); - }); - } - ], function(err) { - if(err) return done(err); - - assert.deepEqual(sourceData, targetData); - done(); - }); - }); - }); - }); - }); - - describe('Model._getACLModel()', function() { - it('should return the subclass of ACL', function() { - var Model = require('../../').Model; - var acl = ACL.extend('acl'); - Model._ACL(null); // Reset the ACL class for the base model - var model = Model._ACL(); - assert.equal(model, acl); - }); - }); } From e026a7f52c15d34776b385b4a0805627efe25939 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 20:07:59 -0700 Subject: [PATCH 17/74] fixup! unskip failing tests --- test/model.test.js | 32 ++++++++++++++++++++++++- test/util/model-tests.js | 50 ++-------------------------------------- 2 files changed, 33 insertions(+), 49 deletions(-) diff --git a/test/model.test.js b/test/model.test.js index 4abb4b898..dbf27c886 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -11,6 +11,36 @@ describe('Model', function() { connector: loopback.Memory } }); + + describe('Model.validatesUniquenessOf(property, options)', function() { + it("Ensure the value for `property` is unique", function(done) { + User.validatesUniquenessOf('email', {message: 'email is not unique'}); + + var joe = new User({email: 'joe@joe.com'}); + var joe2 = new User({email: 'joe@joe.com'}); + + joe.save(function () { + joe2.save(function (err) { + assert(err, 'should get a validation error'); + assert(joe2.errors.email, 'model should have email error'); + + done(); + }); + }); + }); + }); + + describe('Model.attachTo(dataSource)', function() { + it("Attach a model to a [DataSource](#data-source)", function() { + var MyModel = loopback.createModel('my-model', {name: String}); + + assert(MyModel.find === undefined, 'should not have data access methods'); + + MyModel.attachTo(dataSource); + + assert(typeof MyModel.find === 'function', 'should have data access methods after attaching to a data source'); + }); + }); }); describe.onServer('Remote Methods', function(){ @@ -62,7 +92,7 @@ describe.onServer('Remote Methods', function(){ app.use(loopback.rest()); app.model(User); }); - + describe('Example Remote Method', function () { it('Call the method using HTTP / REST', function(done) { request(app) diff --git a/test/util/model-tests.js b/test/util/model-tests.js index 302589c1a..0b03a282a 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -1,19 +1,3 @@ -/* - -Before merging notes: - - - must fix the ".skip" tests below before merging - [x] somehow need to handle callback values that are model typed - [x] findById isn't getting an id... perhaps a remoting bug? - - eg. - - User.create({name: 'joe'}, function(err, user) { - assert(user instanceof User); // ...! - }); - -*/ - var async = require('async'); var describe = require('./describe'); var loopback = require('../../'); @@ -120,24 +104,6 @@ describe('Model Tests', function() { }); }); - describe.skip('Model.validatesUniquenessOf(property, options)', function() { - it("Ensure the value for `property` is unique", function(done) { - User.validatesUniquenessOf('email', {message: 'email is not unique'}); - - var joe = new User({email: 'joe@joe.com'}); - var joe2 = new User({email: 'joe@joe.com'}); - - joe.save(function () { - joe2.save(function (err) { - assert(err, 'should get a validation error'); - assert(joe2.errors.email, 'model should have email error'); - - done(); - }); - }); - }); - }); - describe('myModel.isValid()', function() { it("Validate the model instance", function() { User.validatesNumericalityOf('age', {int: true}); @@ -158,19 +124,7 @@ describe('Model Tests', function() { }); }); - describe.skip('Model.attachTo(dataSource)', function() { - it("Attach a model to a [DataSource](#data-source)", function() { - var MyModel = loopback.createModel('my-model', {name: String}); - - assert(MyModel.find === undefined, 'should not have data access methods'); - - MyModel.attachTo(dataSource); - - assert(typeof MyModel.find === 'function', 'should have data access methods after attaching to a data source'); - }); - }); - - describe.skip('Model.create([data], [callback])', function() { + describe('Model.create([data], [callback])', function() { it("Create an instance of Model with given data and save to the attached data source", function(done) { User.create({first: 'Joe', last: 'Bob'}, function(err, user) { assert(user instanceof User); @@ -191,7 +145,7 @@ describe('Model Tests', function() { }); }); - describe.skip('model.updateAttributes(data, [callback])', function() { + describe('model.updateAttributes(data, [callback])', function() { it("Save specified attributes to the attached data source", function(done) { User.create({first: 'joe', age: 100}, function (err, user) { assert(!err); From ae2fb9dea03d38303bf601128eefc0fc2c314e64 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 20:15:01 -0700 Subject: [PATCH 18/74] !fixup use DataModel instead of Model for all data based models --- test/access-token.test.js | 2 +- test/acl.test.js | 2 +- test/change.test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/access-token.test.js b/test/access-token.test.js index 0ac52b493..de5f030b1 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -104,7 +104,7 @@ function createTestApp(testToken, done) { app.use(loopback.rest()); app.enableAuth(); - var TestModel = loopback.Model.extend('test', {}, { + var TestModel = loopback.DataModel.extend('test', {}, { acls: [ { principalType: "ROLE", diff --git a/test/acl.test.js b/test/acl.test.js index 488638e7a..f59581877 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -18,7 +18,7 @@ describe('security scopes', function () { beforeEach(function() { var ds = this.ds = loopback.createDataSource({connector: loopback.Memory}); - testModel = loopback.Model.extend('testModel'); + testModel = loopback.DataModel.extend('testModel'); ACL.attachTo(ds); Role.attachTo(ds); RoleMapping.attachTo(ds); diff --git a/test/change.test.js b/test/change.test.js index fbc24739f..d570f5096 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -9,7 +9,7 @@ describe('Change', function(){ Change = loopback.Change.extend('change'); Change.attachTo(memory); - TestModel = loopback.Model.extend('chtest'); + TestModel = loopback.DataModel.extend('chtest'); this.modelName = TestModel.modelName; TestModel.attachTo(memory); }); From f8b5fa11ec78e28669938333817e02b00057876d Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 2 May 2014 21:19:14 -0700 Subject: [PATCH 19/74] All tests passing --- lib/models/acl.js | 2 +- lib/models/application.js | 2 +- lib/models/checkpoint.js | 6 ++-- lib/models/data-model.js | 3 +- lib/models/user.js | 6 ++-- test/app.test.js | 14 +++++--- test/data-source.test.js | 58 ++++++++++++++++++---------------- test/hidden-properties.test.js | 19 +++++++---- test/model.test.js | 42 +++++++++++++++++++++++- test/util/model-tests.js | 24 ++------------ 10 files changed, 106 insertions(+), 70 deletions(-) diff --git a/lib/models/acl.js b/lib/models/acl.js index d7f18b6f0..27b53fccd 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -91,7 +91,7 @@ var ACLSchema = { * @inherits Model */ -var ACL = loopback.createModel('ACL', ACLSchema); +var ACL = loopback.DataModel.extend('ACL', ACLSchema); ACL.ALL = AccessContext.ALL; diff --git a/lib/models/application.js b/lib/models/application.js index f537559e1..8d70f47e0 100644 --- a/lib/models/application.js +++ b/lib/models/application.js @@ -113,7 +113,7 @@ function generateKey(hmacKey, algorithm, encoding) { * @inherits {Model} */ -var Application = loopback.createModel('Application', ApplicationSchema); +var Application = loopback.DataModel.extend('Application', ApplicationSchema); /*! * A hook to generate keys before creation diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index 22248bcb4..71d4797ed 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var Model = require('../loopback').Model +var DataModel = require('../loopback').DataModel , loopback = require('../loopback') , assert = require('assert'); @@ -32,10 +32,10 @@ var options = { * @property sourceId {String} the source identifier * * @class - * @inherits {Model} + * @inherits {DataModel} */ -var Checkpoint = module.exports = Model.extend('Checkpoint', properties, options); +var Checkpoint = module.exports = DataModel.extend('Checkpoint', properties, options); /** * Get the current checkpoint id diff --git a/lib/models/data-model.js b/lib/models/data-model.js index d950f7c88..8b89694f2 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -404,8 +404,9 @@ DataModel.setupRemoting = function() { setRemoting(DataModel.destroyAll, { description: 'Delete all matching records', accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, - http: {verb: 'delete', path: '/'} + http: {verb: 'del', path: '/'} }); + DataModel.destroyAll.shared = false; setRemoting(DataModel.deleteById, { description: 'Delete a model instance by id from the data source', diff --git a/lib/models/user.js b/lib/models/user.js index 374fe7c4c..605b4287e 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var Model = require('../loopback').Model +var DataModel = require('../loopback').DataModel , loopback = require('../loopback') , path = require('path') , SALT_WORK_FACTOR = 10 @@ -126,7 +126,7 @@ var options = { * @inherits {Model} */ -var User = module.exports = Model.extend('User', properties, options); +var User = module.exports = DataModel.extend('User', properties, options); /** * Login a user by with the given `credentials`. @@ -414,7 +414,7 @@ User.resetPassword = function(options, cb) { User.setup = function () { // We need to call the base class's setup method - Model.setup.call(this); + DataModel.setup.call(this); var UserModel = this; // max ttl diff --git a/test/app.test.js b/test/app.test.js index 47f317954..3aae923e7 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,5 +1,7 @@ var path = require('path'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); +var loopback = require('../'); +var DataModel = loopback.DataModel; describe('app', function() { @@ -11,21 +13,24 @@ describe('app', function() { }); it("Expose a `Model` to remote clients", function() { - var Color = db.createModel('color', {name: String}); + var Color = DataModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); expect(app.models()).to.eql([Color]); }); it('uses singlar name as app.remoteObjects() key', function() { - var Color = db.createModel('color', {name: String}); + var Color = DataModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); expect(app.remoteObjects()).to.eql({ color: Color }); }); it('uses singular name as shared class name', function() { - var Color = db.createModel('color', {name: String}); + var Color = DataModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); expect(app.remotes().exports).to.eql({ color: Color }); }); @@ -33,8 +38,9 @@ describe('app', function() { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { if (err) return done(err); - var Color = db.createModel('color', {name: String}); + var Color = DataModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); request(app).get('/colors').expect(200, done); }); }); diff --git a/test/data-source.test.js b/test/data-source.test.js index eb0e15375..46db9be1b 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -36,38 +36,42 @@ describe('DataSource', function() { }); }); - describe('dataSource.operations()', function() { - it("List the enabled and disabled operations", function() { + describe('DataModel Methods', function() { + it("List the enabled and disabled methods", function() { + var TestModel = loopback.DataModel.extend('TestDataModel'); + TestModel.attachTo(loopback.memory()); + // assert the defaults // - true: the method should be remote enabled // - false: the method should not be remote enabled // - - existsAndShared('_forDB', false); - existsAndShared('create', true); - existsAndShared('updateOrCreate', true); - existsAndShared('upsert', true); - existsAndShared('findOrCreate', false); - existsAndShared('exists', true); - existsAndShared('find', true); - existsAndShared('findOne', true); - existsAndShared('destroyAll', false); - existsAndShared('count', true); - existsAndShared('include', false); - existsAndShared('relationNameFor', false); - existsAndShared('hasMany', false); - existsAndShared('belongsTo', false); - existsAndShared('hasAndBelongsToMany', false); - existsAndShared('save', false); - existsAndShared('isNewRecord', false); - existsAndShared('_adapter', false); - existsAndShared('destroyById', true); - existsAndShared('destroy', false); - existsAndShared('updateAttributes', true); - existsAndShared('reload', false); + existsAndShared(TestModel, '_forDB', false); + existsAndShared(TestModel, 'create', true); + existsAndShared(TestModel, 'updateOrCreate', true); + existsAndShared(TestModel, 'upsert', true); + existsAndShared(TestModel, 'findOrCreate', false); + existsAndShared(TestModel, 'exists', true); + existsAndShared(TestModel, 'find', true); + existsAndShared(TestModel, 'findOne', true); + existsAndShared(TestModel, 'destroyAll', false); + existsAndShared(TestModel, 'count', true); + existsAndShared(TestModel, 'include', false); + existsAndShared(TestModel, 'relationNameFor', false); + existsAndShared(TestModel, 'hasMany', false); + existsAndShared(TestModel, 'belongsTo', false); + existsAndShared(TestModel, 'hasAndBelongsToMany', false); + // existsAndShared(TestModel.prototype, 'updateAttributes', true); + existsAndShared(TestModel.prototype, 'save', false); + existsAndShared(TestModel.prototype, 'isNewRecord', false); + existsAndShared(TestModel.prototype, '_adapter', false); + existsAndShared(TestModel.prototype, 'destroy', false); + existsAndShared(TestModel.prototype, 'reload', false); - function existsAndShared(name, isRemoteEnabled) { - var op = memory.getOperation(name); - assert(op.remoteEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); + function existsAndShared(scope, name, isRemoteEnabled) { + var fn = scope[name]; + assert(fn, name + ' should be defined!'); + console.log(name, fn.shared, isRemoteEnabled); + assert(!!fn.shared === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); } }); }); diff --git a/test/hidden-properties.test.js b/test/hidden-properties.test.js index 356c20f7b..d1d8740ba 100644 --- a/test/hidden-properties.test.js +++ b/test/hidden-properties.test.js @@ -3,15 +3,20 @@ var loopback = require('../'); describe('hidden properties', function () { beforeEach(function (done) { var app = this.app = loopback(); - var Product = this.Product = app.model('product', { - options: {hidden: ['secret']}, - dataSource: loopback.memory() - }); - var Category = this.Category = this.app.model('category', { - dataSource: loopback.memory() - }); + var Product = this.Product = loopback.DataModel.extend('product', + {}, + {hidden: ['secret']} + ); + Product.attachTo(loopback.memory()); + + var Category = this.Category = loopback.DataModel.extend('category'); + Category.attachTo(loopback.memory()); Category.hasMany(Product); + + app.model(Product); + app.model(Category); app.use(loopback.rest()); + Category.create({ name: 'my category' }, function(err, category) { diff --git a/test/model.test.js b/test/model.test.js index dbf27c886..e5f7425b5 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -14,6 +14,22 @@ describe('Model', function() { describe('Model.validatesUniquenessOf(property, options)', function() { it("Ensure the value for `property` is unique", function(done) { + var User = DataModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }); + + var dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + + User.attachTo(dataSource); + User.validatesUniquenessOf('email', {message: 'email is not unique'}); var joe = new User({email: 'joe@joe.com'}); @@ -33,6 +49,9 @@ describe('Model', function() { describe('Model.attachTo(dataSource)', function() { it("Attach a model to a [DataSource](#data-source)", function() { var MyModel = loopback.createModel('my-model', {name: String}); + var dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); assert(MyModel.find === undefined, 'should not have data access methods'); @@ -92,6 +111,27 @@ describe.onServer('Remote Methods', function(){ app.use(loopback.rest()); app.model(User); }); + + describe('Model.destroyAll(callback)', function() { + it("Delete all Model instances from data source", function(done) { + (new TaskEmitter()) + .task(User, 'create', {first: 'jill'}) + .task(User, 'create', {first: 'bob'}) + .task(User, 'create', {first: 'jan'}) + .task(User, 'create', {first: 'sam'}) + .task(User, 'create', {first: 'suzy'}) + .on('done', function () { + User.count(function (err, count) { + User.destroyAll(function () { + User.count(function (err, count) { + assert.equal(count, 0); + done(); + }); + }); + }); + }); + }); + }); describe('Example Remote Method', function () { it('Call the method using HTTP / REST', function(done) { @@ -326,7 +366,7 @@ describe.onServer('Remote Methods', function(){ describe('Model.extend()', function(){ it('Create a new model by extending an existing model', function() { - var User = loopback.Model.extend('test-user', { + var User = loopback.DataModel.extend('test-user', { email: String }); diff --git a/test/util/model-tests.js b/test/util/model-tests.js index 0b03a282a..7927f2b3c 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -47,6 +47,8 @@ describe('Model Tests', function() { trackChanges: true }); + // enable destroy all for testing + User.destroyAll.shared = true; User.attachTo(dataSource); }); @@ -209,28 +211,6 @@ describe('Model Tests', function() { }); }); - describe('Model.destroyAll(callback)', function() { - it("Delete all Model instances from data source", function(done) { - (new TaskEmitter()) - .task(User, 'create', {first: 'jill'}) - .task(User, 'create', {first: 'bob'}) - .task(User, 'create', {first: 'jan'}) - .task(User, 'create', {first: 'sam'}) - .task(User, 'create', {first: 'suzy'}) - .on('done', function () { - User.count(function (err, count) { - assert.equal(count, 5); - User.destroyAll(function () { - User.count(function (err, count) { - assert.equal(count, 0); - done(); - }); - }); - }); - }); - }); - }); - describe('Model.findById(id, callback)', function() { it("Find an instance by id", function(done) { User.create({first: 'michael', last: 'jordan', id: 23}, function () { From a3a68287091a6380a722e9cdb45bf79dc7cfee09 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 6 May 2014 13:31:23 -0700 Subject: [PATCH 20/74] Move replication implementation to DataModel --- lib/connectors/remote.js | 4 + lib/models/change.js | 8 +- lib/models/data-model.js | 479 ++++++++++++++++++++++++++++++- lib/models/model.js | 363 ----------------------- test/e2e/remote-connector.e2e.js | 2 +- test/model.test.js | 7 +- test/remote-connector.test.js | 42 +++ 7 files changed, 535 insertions(+), 370 deletions(-) diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 23f469a85..bbbedb1ac 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -5,6 +5,7 @@ var assert = require('assert'); var remoting = require('strong-remoting'); var compat = require('../compat'); +var DataAccessObject = require('loopback-datasource-juggler/lib/dao'); /** * Export the RemoteConnector class. @@ -26,6 +27,9 @@ function RemoteConnector(settings) { this.port = settings.port || 3000; this.remotes = remoting.create(); + // TODO(ritch) make sure this name works with Model.getSourceId() + this.name = 'remote-connector'; + if(settings.url) { this.url = settings.url; } else { diff --git a/lib/models/change.js b/lib/models/change.js index 3eab893bc..7a09373dc 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -77,7 +77,6 @@ Change.setup = function() { } Change.setup(); - /** * Track the recent change of the given modelIds. * @@ -369,10 +368,12 @@ Change.diff = function(modelName, since, remoteChanges, callback) { */ Change.rectifyAll = function(cb) { + var Change = this; // this should be optimized this.find(function(err, changes) { if(err) return cb(err); changes.forEach(function(change) { + change = new Change(change); change.rectify(); }); }); @@ -393,6 +394,11 @@ Change.getCheckpointModel = function() { return checkpointModel; } +Change.handleError = function(err) { + if(!this.settings.ignoreErrors) { + throw err; + } +} /** * When two changes conflict a conflict is created. diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 8b89694f2..5aae75362 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -4,7 +4,8 @@ var Model = require('./model'); var RemoteObjects = require('strong-remoting'); -var DataAccess = require('loopback-datasource-juggler/lib/dao'); +var assert = require('assert'); +var async = require('async'); /** * Extends Model with basic query and CRUD support. @@ -42,6 +43,15 @@ DataModel.setup = function setupDataModel() { return val ? new DataModel(val) : val; }); + + // enable change tracking (usually for replication) + if(this.settings.trackChanges) { + DataModel._defineChangeModel(); + DataModel.once('dataSourceAttached', function() { + DataModel.enableChangeTracking(); + }); + } + DataModel.setupRemoting(); } @@ -232,9 +242,66 @@ DataModel.count = function (where, cb) { */ DataModel.prototype.save = function (options, callback) { - throwNotAttached(this.constructor.modelName, 'save'); + var Model = this.constructor; + + if (typeof options == 'function') { + callback = options; + options = {}; + } + + callback = callback || function () { + }; + options = options || {}; + + if (!('validate' in options)) { + options.validate = true; + } + if (!('throws' in options)) { + options.throws = false; + } + + var inst = this; + var data = inst.toObject(true); + var id = this.getId(); + + if (!id) { + return Model.create(this, callback); + } + + // validate first + if (!options.validate) { + return save(); + } + + inst.isValid(function (valid) { + if (valid) { + save(); + } else { + var err = new ValidationError(inst); + // throws option is dangerous for async usage + if (options.throws) { + throw err; + } + callback(err, inst); + } + }); + + // then save + function save() { + inst.trigger('save', function (saveDone) { + inst.trigger('update', function (updateDone) { + Model.upsert(inst, function(err) { + inst._initProperties(data); + updateDone.call(inst, function () { + saveDone.call(inst, function () { + callback(err, inst); + }); + }); + }); + }, data); + }, data); + } }; -DataModel.prototype.save._delegate = true; /** * Determine if the data model is new. @@ -354,6 +421,7 @@ DataModel.getIdName = function() { DataModel.setupRemoting = function() { var DataModel = this; var typeName = DataModel.modelName; + var options = DataModel.settings; setRemoting(DataModel.create, { description: 'Create a new instance of the model and persist it into the data source', @@ -428,6 +496,411 @@ DataModel.setupRemoting = function() { returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'put', path: '/'} }); + + if(options.trackChanges) { + setRemoting(DataModel.diff, { + description: 'Get a set of deltas and conflicts since the given checkpoint', + accepts: [ + {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, + {arg: 'remoteChanges', type: 'array', description: 'an array of change objects', + http: {source: 'body'}} + ], + returns: {arg: 'deltas', type: 'array', root: true}, + http: {verb: 'post', path: '/diff'} + }); + + setRemoting(DataModel.changes, { + description: 'Get the changes to a model since a given checkpoint.' + + 'Provide a filter object to reduce the number of results returned.', + accepts: [ + {arg: 'since', type: 'number', description: 'Only return changes since this checkpoint'}, + {arg: 'filter', type: 'object', description: 'Only include changes that match this filter'} + ], + returns: {arg: 'changes', type: 'array', root: true}, + http: {verb: 'get', path: '/changes'} + }); + + setRemoting(DataModel.checkpoint, { + description: 'Create a checkpoint.', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'post', path: '/checkpoint'} + }); + + setRemoting(DataModel.currentCheckpoint, { + description: 'Get the current checkpoint.', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'get', path: '/checkpoint'} + }); + + setRemoting(DataModel.bulkUpdate, { + description: 'Run multiple updates at once. Note: this is not atomic.', + accepts: {arg: 'updates', type: 'array'}, + http: {verb: 'post', path: '/bulk-update'} + }); + } +} + +/** + * Get a set of deltas and conflicts since the given checkpoint. + * + * See `Change.diff()` for details. + * + * @param {Number} since Find deltas since this checkpoint + * @param {Array} remoteChanges An array of change objects + * @param {Function} callback + */ + +DataModel.diff = function(since, remoteChanges, callback) { + var Change = this.getChangeModel(); + Change.diff(this.modelName, since, remoteChanges, callback); +} + +/** + * Get the changes to a model since a given checkpoint. Provide a filter object + * to reduce the number of results returned. + * @param {Number} since Only return changes since this checkpoint + * @param {Object} filter Only include changes that match this filter + * (same as `Model.find(filter, ...)`) + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes An array of `Change` objects + * @end + */ + +DataModel.changes = function(since, filter, callback) { + var idName = this.dataSource.idName(this.modelName); + var Change = this.getChangeModel(); + var model = this; + + filter = filter || {}; + filter.fields = {}; + filter.where = filter.where || {}; + filter.fields[idName] = true; + + // this whole thing could be optimized a bit more + Change.find({ + checkpoint: {gt: since}, + modelName: this.modelName + }, function(err, changes) { + if(err) return cb(err); + var ids = changes.map(function(change) { + return change.modelId.toString(); + }); + filter.where[idName] = {inq: ids}; + model.find(filter, function(err, models) { + if(err) return cb(err); + var modelIds = models.map(function(m) { + return m[idName].toString(); + }); + callback(null, changes.filter(function(ch) { + if(ch.type() === Change.DELETE) return true; + return modelIds.indexOf(ch.modelId) > -1; + })); + }); + }); +} + +/** + * Create a checkpoint. + * + * @param {Function} callback + */ + +DataModel.checkpoint = function(cb) { + var Checkpoint = this.getChangeModel().getCheckpointModel(); + this.getSourceId(function(err, sourceId) { + if(err) return cb(err); + Checkpoint.create({ + sourceId: sourceId + }, cb); + }); +} + +/** + * Get the current checkpoint id. + * + * @callback {Function} callback + * @param {Error} err + * @param {Number} currentCheckpointId + * @end + */ + +DataModel.currentCheckpoint = function(cb) { + var Checkpoint = this.getChangeModel().getCheckpointModel(); + Checkpoint.current(cb); +} + +/** + * Replicate changes since the given checkpoint to the given target model. + * + * @param {Number} [since] Since this checkpoint + * @param {Model} targetModel Target this model class + * @param {Object} [options] + * @param {Object} [options.filter] Replicate models that match this filter + * @callback {Function} [callback] + * @param {Error} err + * @param {Conflict[]} conflicts A list of changes that could not be replicated + * due to conflicts. + */ + +DataModel.replicate = function(since, targetModel, options, callback) { + var lastArg = arguments[arguments.length - 1]; + + if(typeof lastArg === 'function' && arguments.length > 1) { + callback = lastArg; + } + + if(typeof since === 'function' && since.modelName) { + targetModel = since; + since = -1; + } + + options = options || {}; + + var sourceModel = this; + var diff; + var updates; + var Change = this.getChangeModel(); + var TargetChange = targetModel.getChangeModel(); + var changeTrackingEnabled = Change && TargetChange; + + assert( + changeTrackingEnabled, + 'You must enable change tracking before replicating' + ); + + callback = callback || function defaultReplicationCallback(err) { + if(err) throw err; + } + + var tasks = [ + getLocalChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate, + checkpoint + ]; + + async.waterfall(tasks, function(err) { + if(err) return callback(err); + var conflicts = diff.conflicts.map(function(change) { + var sourceChange = new Change({ + modelName: sourceModel.modelName, + modelId: change.modelId + }); + var targetChange = new TargetChange(change); + return new Change.Conflict(sourceChange, targetChange); + }); + + callback && callback(null, conflicts); + }); + + function getLocalChanges(cb) { + sourceModel.changes(since, options.filter, cb); + } + + function getDiffFromTarget(sourceChanges, cb) { + targetModel.diff(since, sourceChanges, cb); + } + + function createSourceUpdates(_diff, cb) { + diff = _diff; + diff.conflicts = diff.conflicts || []; + if(diff && diff.deltas && diff.deltas.length) { + sourceModel.createUpdates(diff.deltas, cb); + } else { + // done + callback(null, []); + } + } + + function bulkUpdate(updates, cb) { + targetModel.bulkUpdate(updates, cb); + } + + function checkpoint() { + var cb = arguments[arguments.length - 1]; + sourceModel.checkpoint(cb); + } +} + +/** + * Create an update list (for `Model.bulkUpdate()`) from a delta list + * (result of `Change.diff()`). + * + * @param {Array} deltas + * @param {Function} callback + */ + +DataModel.createUpdates = function(deltas, cb) { + var Change = this.getChangeModel(); + var updates = []; + var Model = this; + var tasks = []; + + deltas.forEach(function(change) { + var change = new Change(change); + var type = change.type(); + var update = {type: type, change: change}; + switch(type) { + case Change.CREATE: + case Change.UPDATE: + tasks.push(function(cb) { + Model.findById(change.modelId, function(err, inst) { + if(err) return cb(err); + if(inst.toObject) { + update.data = inst.toObject(); + } else { + update.data = inst; + } + updates.push(update); + cb(); + }); + }); + break; + case Change.DELETE: + updates.push(update); + break; + } + }); + + async.parallel(tasks, function(err) { + if(err) return cb(err); + cb(null, updates); + }); +} + +/** + * Apply an update list. + * + * **Note: this is not atomic** + * + * @param {Array} updates An updates list (usually from Model.createUpdates()) + * @param {Function} callback + */ + +DataModel.bulkUpdate = function(updates, callback) { + var tasks = []; + var Model = this; + var idName = this.dataSource.idName(this.modelName); + var Change = this.getChangeModel(); + + updates.forEach(function(update) { + switch(update.type) { + case Change.UPDATE: + case Change.CREATE: + // var model = new Model(update.data); + // tasks.push(model.save.bind(model)); + tasks.push(function(cb) { + var model = new Model(update.data); + model.save(cb); + }); + break; + case Change.DELETE: + var data = {}; + data[idName] = update.change.modelId; + var model = new Model(data); + tasks.push(model.destroy.bind(model)); + break; + } + }); + + async.parallel(tasks, callback); +} + +/** + * Get the `Change` model. + * + * @throws {Error} Throws an error if the change model is not correctly setup. + * @return {Change} + */ + +DataModel.getChangeModel = function() { + var changeModel = this.Change; + var isSetup = changeModel && changeModel.dataSource; + + assert(isSetup, 'Cannot get a setup Change model'); + + return changeModel; +} + +/** + * Get the source identifier for this model / dataSource. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} sourceId + */ + +DataModel.getSourceId = function(cb) { + var dataSource = this.dataSource; + if(!dataSource) { + this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); + } + assert( + dataSource.connector.name, + 'Model.getSourceId: cannot get id without dataSource.connector.name' + ); + var id = [dataSource.connector.name, this.modelName].join('-'); + cb(null, id); +} + +/** + * Enable the tracking of changes made to the model. Usually for replication. + */ + +DataModel.enableChangeTracking = function() { + // console.log('THIS SHOULD NOT RUN ON A MODEL CONNECTED TO A REMOTE DATASOURCE'); + + var Model = this; + var Change = this.Change || this._defineChangeModel(); + var cleanupInterval = Model.settings.changeCleanupInterval || 30000; + + assert(this.dataSource, 'Cannot enableChangeTracking(): ' + this.modelName + + ' is not attached to a dataSource'); + + Change.attachTo(this.dataSource); + Change.getCheckpointModel().attachTo(this.dataSource); + + Model.on('changed', function(obj) { + Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deleted', function(obj) { + Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } + }); + }); + + Model.on('deletedAll', cleanup); + + // initial cleanup + cleanup(); + + // cleanup + setInterval(cleanup, cleanupInterval); + + function cleanup() { + Change.rectifyAll(function(err) { + if(err) { + console.error(Model.modelName + ' Change Cleanup Error:'); + console.error(err); + } + }); + } +} + +DataModel._defineChangeModel = function() { + var BaseChangeModel = require('./change'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change'); } DataModel.setup(); diff --git a/lib/models/model.js b/lib/models/model.js index d28cb7868..310a1e829 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -92,10 +92,6 @@ Model.setup = function () { var ModelCtor = this; var options = this.settings; - if(options.trackChanges) { - this._defineChangeModel(); - } - ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { fn = data; @@ -183,13 +179,6 @@ Model.setup = function () { ModelCtor.sharedCtor.returns = {root: true}; - // enable change tracking (usually for replication) - if(options.trackChanges) { - ModelCtor.once('dataSourceAttached', function() { - ModelCtor.enableChangeTracking(); - }); - } - return ModelCtor; }; @@ -303,355 +292,3 @@ Model.getApp = function(callback) { // setup the initial model Model.setup(); - -/** - * Get a set of deltas and conflicts since the given checkpoint. - * - * See `Change.diff()` for details. - * - * @param {Number} since Find changes since this checkpoint - * @param {Array} remoteChanges An array of change objects - * @param {Function} callback - */ - -Model.diff = function(since, remoteChanges, callback) { - var Change = this.getChangeModel(); - Change.diff(this.modelName, since, remoteChanges, callback); -} - -/** - * Get the changes to a model since a given checkpoing. Provide a filter object - * to reduce the number of results returned. - * @param {Number} since Only return changes since this checkpoint - * @param {Object} filter Only include changes that match this filter - * (same as `Model.find(filter, ...)`) - * @callback {Function} callback - * @param {Error} err - * @param {Array} changes An array of `Change` objects - * @end - */ - -Model.changes = function(since, filter, callback) { - var idName = this.dataSource.idName(this.modelName); - var Change = this.getChangeModel(); - var model = this; - - filter = filter || {}; - filter.fields = {}; - filter.where = filter.where || {}; - filter.fields[idName] = true; - - // this whole thing could be optimized a bit more - Change.find({ - checkpoint: {gt: since}, - modelName: this.modelName - }, function(err, changes) { - if(err) return cb(err); - var ids = changes.map(function(change) { - return change.modelId.toString(); - }); - filter.where[idName] = {inq: ids}; - model.find(filter, function(err, models) { - if(err) return cb(err); - var modelIds = models.map(function(m) { - return m[idName].toString(); - }); - callback(null, changes.filter(function(ch) { - if(ch.type() === Change.DELETE) return true; - return modelIds.indexOf(ch.modelId) > -1; - })); - }); - }); -} - -/** - * Create a checkpoint. - * - * @param {Function} callback - */ - -Model.checkpoint = function(cb) { - var Checkpoint = this.getChangeModel().getCheckpointModel(); - this.getSourceId(function(err, sourceId) { - if(err) return cb(err); - Checkpoint.create({ - sourceId: sourceId - }, cb); - }); -} - -/** - * Get the current checkpoint id. - * - * @callback {Function} callback - * @param {Error} err - * @param {Number} currentCheckpointId - * @end - */ - -Model.currentCheckpoint = function(cb) { - var Checkpoint = this.getChangeModel().getCheckpointModel(); - Checkpoint.current(cb); -} - -/** - * Replicate changes since the given checkpoint to the given target model. - * - * @param {Number} [since] Since this checkpoint - * @param {Model} targetModel Target this model class - * @param {Object} [options] - * @param {Object} [options.filter] Replicate models that match this filter - * @callback {Function} [callback] - * @param {Error} err - * @param {Conflict[]} conflicts A list of changes that could not be replicated - * due to conflicts. - */ - -Model.replicate = function(since, targetModel, options, callback) { - var lastArg = arguments[arguments.length - 1]; - - if(typeof lastArg === 'function' && arguments.length > 1) { - callback = lastArg; - } - - if(typeof since === 'function' && since.modelName) { - targetModel = since; - since = -1; - } - - options = options || {}; - - var sourceModel = this; - var diff; - var updates; - var Change = this.getChangeModel(); - var TargetChange = targetModel.getChangeModel(); - var changeTrackingEnabled = Change && TargetChange; - - assert( - changeTrackingEnabled, - 'You must enable change tracking before replicating' - ); - - var tasks = [ - getLocalChanges, - getDiffFromTarget, - createSourceUpdates, - bulkUpdate, - checkpoint - ]; - - async.waterfall(tasks, function(err) { - if(err) return callback(err); - var conflicts = diff.conflicts.map(function(change) { - var sourceChange = new Change({ - modelName: sourceModel.modelName, - modelId: change.modelId - }); - var targetChange = new TargetChange(change); - return new Change.Conflict(sourceChange, targetChange); - }); - - callback && callback(null, conflicts); - }); - - function getLocalChanges(cb) { - sourceModel.changes(since, options.filter, cb); - } - - function getDiffFromTarget(sourceChanges, cb) { - targetModel.diff(since, sourceChanges, cb); - } - - function createSourceUpdates(_diff, cb) { - diff = _diff; - diff.conflicts = diff.conflicts || []; - sourceModel.createUpdates(diff.deltas, cb); - } - - function bulkUpdate(updates, cb) { - targetModel.bulkUpdate(updates, cb); - } - - function checkpoint() { - var cb = arguments[arguments.length - 1]; - sourceModel.checkpoint(cb); - } -} - -/** - * Create an update list (for `Model.bulkUpdate()`) from a delta list - * (result of `Change.diff()`). - * - * @param {Array} deltas - * @param {Function} callback - */ - -Model.createUpdates = function(deltas, cb) { - var Change = this.getChangeModel(); - var updates = []; - var Model = this; - var tasks = []; - - deltas.forEach(function(change) { - var change = new Change(change); - var type = change.type(); - var update = {type: type, change: change}; - switch(type) { - case Change.CREATE: - case Change.UPDATE: - tasks.push(function(cb) { - Model.findById(change.modelId, function(err, inst) { - if(err) return cb(err); - if(inst.toObject) { - update.data = inst.toObject(); - } else { - update.data = inst; - } - updates.push(update); - cb(); - }); - }); - break; - case Change.DELETE: - updates.push(update); - break; - } - }); - - async.parallel(tasks, function(err) { - if(err) return cb(err); - cb(null, updates); - }); -} - -/** - * Apply an update list. - * - * **Note: this is not atomic** - * - * @param {Array} updates An updates list (usually from Model.createUpdates()) - * @param {Function} callback - */ - -Model.bulkUpdate = function(updates, callback) { - var tasks = []; - var Model = this; - var idName = this.dataSource.idName(this.modelName); - var Change = this.getChangeModel(); - - updates.forEach(function(update) { - switch(update.type) { - case Change.UPDATE: - case Change.CREATE: - // var model = new Model(update.data); - // tasks.push(model.save.bind(model)); - tasks.push(function(cb) { - var model = new Model(update.data); - model.save(cb); - }); - break; - case Change.DELETE: - var data = {}; - data[idName] = update.change.modelId; - var model = new Model(data); - tasks.push(model.destroy.bind(model)); - break; - } - }); - - async.parallel(tasks, callback); -} - -/** - * Get the `Change` model. - * - * @throws {Error} Throws an error if the change model is not correctly setup. - * @return {Change} - */ - -Model.getChangeModel = function() { - var changeModel = this.Change; - var isSetup = changeModel && changeModel.dataSource; - - assert(isSetup, 'Cannot get a setup Change model'); - - return changeModel; -} - -/** - * Get the source identifier for this model / dataSource. - * - * @callback {Function} callback - * @param {Error} err - * @param {String} sourceId - */ - -Model.getSourceId = function(cb) { - var dataSource = this.dataSource; - if(!dataSource) { - this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); - } - assert( - dataSource.connector.name, - 'Model.getSourceId: cannot get id without dataSource.connector.name' - ); - var id = [dataSource.connector.name, this.modelName].join('-'); - cb(null, id); -} - -/** - * Enable the tracking of changes made to the model. Usually for replication. - */ - -Model.enableChangeTracking = function() { - var Model = this; - var Change = this.Change || this._defineChangeModel(); - var cleanupInterval = Model.settings.changeCleanupInterval || 30000; - - assert(this.dataSource, 'Cannot enableChangeTracking(): ' + this.modelName - + ' is not attached to a dataSource'); - - Change.attachTo(this.dataSource); - Change.getCheckpointModel().attachTo(this.dataSource); - - Model.on('changed', function(obj) { - Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { - if(err) { - console.error(Model.modelName + ' Change Tracking Error:'); - console.error(err); - } - }); - }); - - Model.on('deleted', function(obj) { - Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { - if(err) { - console.error(Model.modelName + ' Change Tracking Error:'); - console.error(err); - } - }); - }); - - Model.on('deletedAll', cleanup); - - // initial cleanup - cleanup(); - - // cleanup - setInterval(cleanup, cleanupInterval); - - function cleanup() { - Change.rectifyAll(function(err) { - if(err) { - console.error(Model.modelName + ' Change Cleanup Error:'); - console.error(err); - } - }); - } -} - -Model._defineChangeModel = function() { - var BaseChangeModel = require('./change'); - return this.Change = BaseChangeModel.extend(this.modelName + '-change'); -} diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 791b43c57..47c0d7bb0 100644 --- a/test/e2e/remote-connector.e2e.js +++ b/test/e2e/remote-connector.e2e.js @@ -30,7 +30,7 @@ describe('RemoteConnector', function() { }); m.save(function(err, data) { if(err) return done(err); - assert(m.id); + assert(data.foo === 'bar'); done(); }); }); diff --git a/test/model.test.js b/test/model.test.js index e5f7425b5..ec23c72bd 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -512,12 +512,15 @@ describe.onServer('Remote Methods', function(){ beforeEach(function(done) { var test = this; this.dataSource = dataSource; - var SourceModel = this.SourceModel = this.dataSource.createModel('SourceModel', {}, { + var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { trackChanges: true }); - var TargetModel = this.TargetModel = this.dataSource.createModel('TargetModel', {}, { + SourceModel.attachTo(dataSource); + + var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { trackChanges: true }); + TargetModel.attachTo(dataSource); var createOne = SourceModel.create.bind(SourceModel, { name: 'baz' diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 9e6f875de..5871299b6 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -27,4 +27,46 @@ describe('RemoteConnector', function() { remoteApp.model(RemoteModel); } }); + + beforeEach(function(done) { + var test = this; + remoteApp = this.remoteApp = loopback(); + remoteApp.use(loopback.rest()); + var ServerModel = this.ServerModel = loopback.DataModel.extend('TestModel'); + + remoteApp.model(ServerModel); + + remoteApp.listen(0, function() { + test.remote = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + done(); + }); + }); + + it('should support the save method', function (done) { + var calledServerCreate = false; + var RemoteModel = loopback.DataModel.extend('TestModel'); + RemoteModel.attachTo(this.remote); + + var ServerModel = this.ServerModel; + + ServerModel.create = function(data, cb) { + calledServerCreate = true; + data.id = 1; + cb(null, data); + } + + ServerModel.setupRemoting(); + + var m = new RemoteModel({foo: 'bar'}); + console.log(m.save.toString()); + m.save(function(err, inst) { + assert(inst instanceof RemoteModel); + assert(calledServerCreate); + done(); + }); + }); }); From 5bf1f76762b96a104624dad659880bb896e27de8 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 7 May 2014 07:44:00 -0700 Subject: [PATCH 21/74] !fixup Test cleanup --- test/data-source.test.js | 1 - test/model.test.js | 8 +------- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/test/data-source.test.js b/test/data-source.test.js index 46db9be1b..ee29b3b1e 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -70,7 +70,6 @@ describe('DataSource', function() { function existsAndShared(scope, name, isRemoteEnabled) { var fn = scope[name]; assert(fn, name + ' should be defined!'); - console.log(name, fn.shared, isRemoteEnabled); assert(!!fn.shared === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); } }); diff --git a/test/model.test.js b/test/model.test.js index ec23c72bd..28753e29b 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -5,7 +5,7 @@ var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); var DataModel = loopback.DataModel; -describe('Model', function() { +describe('Model / DataModel', function() { defineModelTestsWithDataSource({ dataSource: { connector: loopback.Memory @@ -209,12 +209,6 @@ describe.onServer('Remote Methods', function(){ }); describe('Remote Method invoking context', function () { - // describe('ctx.user', function() { - // it("The remote user model calling the method remotely", function(done) { - // done(new Error('test not implemented')); - // }); - // }); - describe('ctx.req', function() { it("The express ServerRequest object", function(done) { var hookCalled = false; From 908221416eccee296eab020519f477f26d4636b6 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 9 May 2014 17:19:32 -0700 Subject: [PATCH 22/74] Fix issues when using MongoDB for replication --- lib/models/change.js | 13 ++++- lib/models/checkpoint.js | 30 +++++++++-- lib/models/data-model.js | 111 +++++++++++++++++++++++++++++---------- 3 files changed, 118 insertions(+), 36 deletions(-) diff --git a/lib/models/change.js b/lib/models/change.js index 7a09373dc..410bab7ab 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -179,7 +179,7 @@ Change.prototype.rectify = function(cb) { function updateCheckpoint(cb) { change.constructor.getCheckpointModel().current(function(err, checkpoint) { if(err) return Change.handleError(err); - change.checkpoint = ++checkpoint; + change.checkpoint = checkpoint; cb(); }); } @@ -328,7 +328,7 @@ Change.diff = function(modelName, since, remoteChanges, callback) { where: { modelName: modelName, modelId: {inq: modelIds}, - checkpoint: {gt: since} + checkpoint: {gte: since} } }, function(err, localChanges) { if(err) return callback(err); @@ -400,6 +400,15 @@ Change.handleError = function(err) { } } +Change.prototype.getModelId = function() { + // TODO(ritch) get rid of the need to create an instance + var Model = this.constructor.settings.model; + var id = this.modelId; + var m = new Model(); + m.setId(id); + return m.getId(); +} + /** * When two changes conflict a conflict is created. * diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index 71d4797ed..c1fa7d70f 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -11,8 +11,8 @@ var DataModel = require('../loopback').DataModel */ var properties = { - id: {type: Number, generated: true, id: true}, - time: {type: Number, generated: true, default: Date.now}, + seq: {type: Number}, + time: {type: Date, default: Date}, sourceId: {type: String} }; @@ -45,13 +45,33 @@ var Checkpoint = module.exports = DataModel.extend('Checkpoint', properties, opt */ Checkpoint.current = function(cb) { + var Checkpoint = this; this.find({ limit: 1, - sort: 'id DESC' + sort: 'seq DESC' }, function(err, checkpoints) { if(err) return cb(err); - var checkpoint = checkpoints[0] || {id: 0}; - cb(null, checkpoint.id); + var checkpoint = checkpoints[0]; + if(checkpoint) { + cb(null, checkpoint.seq); + } else { + Checkpoint.create({seq: 0}, function(err, checkpoint) { + if(err) return cb(err); + cb(null, checkpoint.seq); + }); + } }); } +Checkpoint.beforeSave = function(next, model) { + if(!model.getId() && model.seq === undefined) { + model.constructor.current(function(err, seq) { + if(err) return next(err); + model.seq = seq + 1; + next(); + }); + } else { + next(); + } +} + diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 5aae75362..ade8d8bfe 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -3,6 +3,7 @@ */ var Model = require('./model'); +var loopback = require('../loopback'); var RemoteObjects = require('strong-remoting'); var assert = require('assert'); var async = require('async'); @@ -505,7 +506,7 @@ DataModel.setupRemoting = function() { {arg: 'remoteChanges', type: 'array', description: 'an array of change objects', http: {source: 'body'}} ], - returns: {arg: 'deltas', type: 'array', root: true}, + returns: {arg: 'result', type: 'object', root: true}, http: {verb: 'post', path: '/diff'} }); @@ -532,11 +533,29 @@ DataModel.setupRemoting = function() { http: {verb: 'get', path: '/checkpoint'} }); + setRemoting(DataModel.createUpdates, { + description: 'Create an update list from a delta list', + accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, + returns: {arg: 'updates', type: 'array', root: true}, + http: {verb: 'post', path: '/create-updates'} + }); + setRemoting(DataModel.bulkUpdate, { description: 'Run multiple updates at once. Note: this is not atomic.', accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); + + setRemoting(DataModel.rectifyAllChanges, { + description: 'Rectify all Model changes.', + http: {verb: 'post', path: '/rectify-all'} + }); + + setRemoting(DataModel.rectifyChange, { + description: 'Tell loopback that a change to the model with the given id has occurred.', + accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, + http: {verb: 'post', path: '/:id/rectify-change'} + }); } } @@ -568,6 +587,12 @@ DataModel.diff = function(since, remoteChanges, callback) { */ DataModel.changes = function(since, filter, callback) { + if(typeof since === 'function') { + filter = {}; + callback = since; + since = -1; + } + var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); var model = this; @@ -577,14 +602,14 @@ DataModel.changes = function(since, filter, callback) { filter.where = filter.where || {}; filter.fields[idName] = true; - // this whole thing could be optimized a bit more + // TODO(ritch) this whole thing could be optimized a bit more Change.find({ checkpoint: {gt: since}, modelName: this.modelName }, function(err, changes) { if(err) return cb(err); var ids = changes.map(function(change) { - return change.modelId.toString(); + return change.getModelId(); }); filter.where[idName] = {inq: ids}; model.find(filter, function(err, models) { @@ -709,7 +734,7 @@ DataModel.replicate = function(since, targetModel, options, callback) { if(diff && diff.deltas && diff.deltas.length) { sourceModel.createUpdates(diff.deltas, cb); } else { - // done + // nothing to replicate callback(null, []); } } @@ -863,44 +888,72 @@ DataModel.enableChangeTracking = function() { Change.getCheckpointModel().attachTo(this.dataSource); Model.on('changed', function(obj) { - Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { - if(err) { - console.error(Model.modelName + ' Change Tracking Error:'); - console.error(err); - } - }); + Model.rectifyChange(obj.getId(), Model.handleChangeError); }); - Model.on('deleted', function(obj) { - Change.rectifyModelChanges(Model.modelName, [obj.id], function(err) { - if(err) { - console.error(Model.modelName + ' Change Tracking Error:'); - console.error(err); - } - }); + Model.on('deleted', function(id) { + Model.rectifyChange(id, Model.handleChangeError); }); Model.on('deletedAll', cleanup); - // initial cleanup - cleanup(); + if(loopback.isServer) { + // initial cleanup + cleanup(); - // cleanup - setInterval(cleanup, cleanupInterval); + // cleanup + setInterval(cleanup, cleanupInterval); - function cleanup() { - Change.rectifyAll(function(err) { - if(err) { - console.error(Model.modelName + ' Change Cleanup Error:'); - console.error(err); - } - }); + function cleanup() { + Model.rectifyAllChanges(function(err) { + if(err) { + console.error(Model.modelName + ' Change Cleanup Error:'); + console.error(err); + } + }); + } } } DataModel._defineChangeModel = function() { var BaseChangeModel = require('./change'); - return this.Change = BaseChangeModel.extend(this.modelName + '-change'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change', + {}, + { + model: this + } + ); +} + +DataModel.rectifyAllChanges = function(callback) { + this.getChangeModel().rectifyAll(callback); +} + +/** + * Handle a change error. Override this method in a subclassing model to customize + * change error handling. + * + * @param {Error} err + */ + +DataModel.handleChangeError = function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } +} + +/** + * Tell loopback that a change to the model with the given id has occurred. + * + * @param {*} id The id of the model that has changed + * @callback {Function} callback + * @param {Error} err + */ + +DataModel.rectifyChange = function(id, callback) { + var Change = this.getChangeModel(); + Change.rectifyModelChanges(this.modelName, [id], callback); } DataModel.setup(); From 4bab42478f6bfe21c1d3c8af59fc495ef94d3bf5 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 12 May 2014 10:36:10 -0700 Subject: [PATCH 23/74] Add error logging for missing data --- lib/models/data-model.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index ade8d8bfe..7580c9ee3 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -773,6 +773,10 @@ DataModel.createUpdates = function(deltas, cb) { tasks.push(function(cb) { Model.findById(change.modelId, function(err, inst) { if(err) return cb(err); + if(!inst) { + console.error('missing data for change:', change); + return callback(); + } if(inst.toObject) { update.data = inst.toObject(); } else { From 1e2ad9fba9b570b78e72d76bdb035c657fdc7ff3 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 14 May 2014 09:20:04 -0700 Subject: [PATCH 24/74] Change#getModel(), Doc cleanup, Conflict event --- lib/models/change.js | 158 +++++++++++++++++++++++++++++++++------ lib/models/data-model.js | 46 +++++++----- 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/lib/models/change.js b/lib/models/change.js index 410bab7ab..c55db2d95 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -66,6 +66,7 @@ Change.Conflict = Conflict; */ Change.setup = function() { + DataModel.setup.call(this); var Change = this; Change.getter.id = function() { @@ -271,7 +272,9 @@ Change.prototype.getModelCtor = function() { Change.prototype.equals = function(change) { if(!change) return false; - return change.rev === this.rev; + var thisRev = this.rev || null; + var thatRev = change.rev || null; + return thisRev === thatRev; } /** @@ -402,56 +405,169 @@ Change.handleError = function(err) { Change.prototype.getModelId = function() { // TODO(ritch) get rid of the need to create an instance - var Model = this.constructor.settings.model; + var Model = this.constructor.settings.trackModel; var id = this.modelId; var m = new Model(); m.setId(id); return m.getId(); } +Change.prototype.getModel = function(callback) { + var Model = this.constructor.settings.trackModel; + var id = this.getModelId(); + Model.findById(id, callback); +} + /** * When two changes conflict a conflict is created. * * **Note: call `conflict.fetch()` to get the `target` and `source` models. * - * @param {Change} sourceChange The change object for the source model - * @param {Change} targetChange The conflicting model's change object - * @property {Model} source The source model instance - * @property {Model} target The target model instance + * @param {*} sourceModelId + * @param {*} targetModelId + * @property {ModelClass} source The source model instance + * @property {ModelClass} target The target model instance */ -function Conflict(sourceChange, targetChange) { - this.sourceChange = sourceChange; - this.targetChange = targetChange; +function Conflict(modelId, SourceModel, TargetModel) { + this.SourceModel = SourceModel; + this.TargetModel = TargetModel; + this.SourceChange = SourceModel.getChangeModel(); + this.TargetChange = TargetModel.getChangeModel(); + this.modelId = modelId; } -Conflict.prototype.fetch = function(cb) { +/** + * Fetch the conflicting models. + * + * @callback {Function} callback + * @param {Error} + * @param {DataModel} source + * @param {DataModel} target + */ + +Conflict.prototype.models = function(cb) { var conflict = this; - var tasks = [ + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var source; + var target; + + async.parallel([ getSourceModel, getTargetModel - ]; + ], done); - async.parallel(tasks, cb); - - function getSourceModel(change, cb) { - conflict.sourceModel.getModel(function(err, model) { + function getSourceModel(cb) { + SourceModel.findById(conflict.modelId, function(err, model) { if(err) return cb(err); - conflict.source = model; + source = model; cb(); }); } function getTargetModel(cb) { - conflict.targetModel.getModel(function(err, model) { + TargetModel.findById(conflict.modelId, function(err, model) { if(err) return cb(err); - conflict.target = model; + target = model; cb(); }); } + + function done(err) { + if(err) return cb(err); + cb(null, source, target); + } } +/** + * Get the conflicting changes. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} sourceChange + * @param {Change} targetChange + */ + +Conflict.prototype.changes = function(cb) { + var conflict = this; + var sourceChange; + var targetChange; + + async.parallel([ + getSourceChange, + getTargetChange + ], done); + + function getSourceChange(cb) { + conflict.SourceChange.findOne({ + modelId: conflict.sourceModelId + }, function(err, change) { + if(err) return cb(err); + sourceChange = change; + cb(); + }); + } + + function getTargetChange(cb) { + debugger; + conflict.TargetChange.findOne({ + modelId: conflict.targetModelId + }, function(err, change) { + if(err) return cb(err); + targetChange = change; + cb(); + }); + } + + function done(err) { + if(err) return cb(err); + cb(null, sourceChange, targetChange); + } +} + +/** + * Resolve the conflict. + * + * @callback {Function} callback + * @param {Error} err + */ + Conflict.prototype.resolve = function(cb) { - this.sourceChange.prev = this.targetChange.rev; - this.sourceChange.save(cb); + var conflict = this; + conflict.changes(function(err, sourceChange, targetChange) { + if(err) return callback(err); + sourceChange.prev = targetChange.rev; + sourceChange.save(cb); + }); +} + +/** + * Determine the conflict type. + * + * ```js + * // possible results are + * Change.UPDATE // => source and target models were updated + * Change.DELETE // => the source and or target model was deleted + * Change.UNKNOWN // => the conflict type is uknown or due to an error + * ``` + * @callback {Function} callback + * @param {Error} err + * @param {String} type The conflict type. + */ + +Conflict.prototype.type = function(cb) { + var conflict = this; + this.changes(function(err, sourceChange, targetChange) { + if(err) return cb(err); + var sourceChangeType = sourceChange.type(); + var targetChangeType = targetChange.type(); + if(sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) { + return cb(null, Change.UPDATE); + } + if(sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) { + return cb(null, Change.DELETE); + } + return cb(null, Change.UNKNOWN); + }); } diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 7580c9ee3..8295d6dd3 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -44,7 +44,6 @@ DataModel.setup = function setupDataModel() { return val ? new DataModel(val) : val; }); - // enable change tracking (usually for replication) if(this.settings.trackChanges) { DataModel._defineChangeModel(); @@ -424,6 +423,8 @@ DataModel.setupRemoting = function() { var typeName = DataModel.modelName; var options = DataModel.settings; + // TODO(ritch) setRemoting should create its own function... + setRemoting(DataModel.create, { description: 'Create a new instance of the model and persist it into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, @@ -592,6 +593,11 @@ DataModel.changes = function(since, filter, callback) { callback = since; since = -1; } + if(typeof filter === 'function') { + callback = filter; + since = -1; + filter = {}; + } var idName = this.dataSource.idName(this.modelName); var Change = this.getChangeModel(); @@ -699,28 +705,16 @@ DataModel.replicate = function(since, targetModel, options, callback) { } var tasks = [ - getLocalChanges, + getSourceChanges, getDiffFromTarget, createSourceUpdates, bulkUpdate, checkpoint ]; - async.waterfall(tasks, function(err) { - if(err) return callback(err); - var conflicts = diff.conflicts.map(function(change) { - var sourceChange = new Change({ - modelName: sourceModel.modelName, - modelId: change.modelId - }); - var targetChange = new TargetChange(change); - return new Change.Conflict(sourceChange, targetChange); - }); - - callback && callback(null, conflicts); - }); + async.waterfall(tasks, done); - function getLocalChanges(cb) { + function getSourceChanges(cb) { sourceModel.changes(since, options.filter, cb); } @@ -735,7 +729,7 @@ DataModel.replicate = function(since, targetModel, options, callback) { sourceModel.createUpdates(diff.deltas, cb); } else { // nothing to replicate - callback(null, []); + done(); } } @@ -747,6 +741,22 @@ DataModel.replicate = function(since, targetModel, options, callback) { var cb = arguments[arguments.length - 1]; sourceModel.checkpoint(cb); } + + function done(err) { + if(err) return callback(err); + + var conflicts = diff.conflicts.map(function(change) { + return new Change.Conflict( + change.modelId, sourceModel, targetModel + ); + }); + + if(conflicts.length) { + sourceModel.emit('conflicts', conflicts); + } + + callback && callback(null, conflicts); + } } /** @@ -924,7 +934,7 @@ DataModel._defineChangeModel = function() { return this.Change = BaseChangeModel.extend(this.modelName + '-change', {}, { - model: this + trackModel: this } ); } From 344601cde4b5185b5cfe74eadabf9996a8916abd Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Wed, 14 May 2014 14:39:30 -0700 Subject: [PATCH 25/74] bump juggler version --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 79496c81b..5f9baed94 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "canonical-json": "0.0.3" }, "peerDependencies": { - "loopback-datasource-juggler": "~1.3.11" + "loopback-datasource-juggler": "^1.4.0" }, "devDependencies": { - "loopback-datasource-juggler": "~1.3.11", + "loopback-datasource-juggler": "^1.4.0", "mocha": "~1.17.1", "strong-task-emitter": "0.0.x", "supertest": "~0.9.0", From d875c512bfa507dd616f3dd1fe591b3965feae43 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 15 May 2014 17:27:02 -0700 Subject: [PATCH 26/74] Rework replication test --- .gitignore | 1 + lib/models/change.js | 24 +-- lib/models/data-model.js | 75 +++++--- test/change.test.js | 16 +- test/fixtures/e2e/app.js | 4 +- test/model.test.js | 79 +------- test/remote-connector.test.js | 1 - test/replication.test.js | 343 ++++++++++++++++++++++++++++++++++ 8 files changed, 412 insertions(+), 131 deletions(-) create mode 100644 test/replication.test.js diff --git a/.gitignore b/.gitignore index a84a7659a..fdcae0c86 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.swp *.swo node_modules +dist diff --git a/lib/models/change.js b/lib/models/change.js index c55db2d95..22d0b2c60 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -195,7 +195,8 @@ Change.prototype.rectify = function(cb) { Change.prototype.currentRevision = function(cb) { var model = this.getModelCtor(); - model.findById(this.modelId, function(err, inst) { + var id = this.getModelId(); + model.findById(id, function(err, inst) { if(err) return Change.handleError(err, cb); if(inst) { cb(null, Change.revisionForInst(inst)); @@ -254,16 +255,6 @@ Change.prototype.type = function() { 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 loopback.getModel(this.modelName); -} - /** * Compare two changes. * @param {Change} change @@ -403,9 +394,18 @@ Change.handleError = function(err) { } } +/** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + +Change.prototype.getModelCtor = function() { + return this.constructor.settings.trackModel; +} + Change.prototype.getModelId = function() { // TODO(ritch) get rid of the need to create an instance - var Model = this.constructor.settings.trackModel; + var Model = this.getModelCtor(); var id = this.modelId; var m = new Model(); m.setId(id); diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 8295d6dd3..5fbe1d4ef 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -62,16 +62,31 @@ DataModel.setup = function setupDataModel() { * @private */ -function setRemoting(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } +function setRemoting(target, name, options) { + var fn = target[name]; + setupFunction(fn, options); + target[name] = createProxy(fn, options); +} + +function createProxy(fn, options) { + var p = function proxy() { + return fn.apply(this, arguments); } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; + + return setupFunction(fn, options); +} + +function setupFunction(fn, options) { + options = options || {}; + for (var opt in options) { + if (options.hasOwnProperty(opt)) { + fn[opt] = options[opt]; + } + } + fn.shared = true; + // allow connectors to override the function by marking as delegate + fn._delegate = true; + return fn; } /*! @@ -425,28 +440,28 @@ DataModel.setupRemoting = function() { // TODO(ritch) setRemoting should create its own function... - setRemoting(DataModel.create, { + setRemoting(DataModel, 'create', { description: 'Create a new instance of the model and persist it into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'post', path: '/'} }); - setRemoting(DataModel.upsert, { + setRemoting(DataModel, 'upsert', { description: 'Update an existing model instance or insert a new one into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'put', path: '/'} }); - setRemoting(DataModel.exists, { + setRemoting(DataModel, 'exists', { description: 'Check whether a model instance exists in the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, returns: {arg: 'exists', type: 'boolean'}, http: {verb: 'get', path: '/:id/exists'} }); - setRemoting(DataModel.findById, { + setRemoting(DataModel, 'findById', { description: 'Find a model instance by id from the data source', accepts: { arg: 'id', type: 'any', description: 'Model id', required: true, @@ -457,42 +472,42 @@ DataModel.setupRemoting = function() { rest: {after: convertNullToNotFoundError} }); - setRemoting(DataModel.find, { + setRemoting(DataModel, 'find', { description: 'Find all instances of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: [typeName], root: true}, http: {verb: 'get', path: '/'} }); - setRemoting(DataModel.findOne, { + setRemoting(DataModel, 'findOne', { description: 'Find first instance of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/findOne'} }); - setRemoting(DataModel.destroyAll, { + setRemoting(DataModel, 'destroyAll', { description: 'Delete all matching records', accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, - http: {verb: 'del', path: '/'} + http: {verb: 'del', path: '/'}, + shared: false }); - DataModel.destroyAll.shared = false; - setRemoting(DataModel.deleteById, { + setRemoting(DataModel, 'removeById', { description: 'Delete a model instance by id from the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, http: {source: 'path'}}, http: {verb: 'del', path: '/:id'} }); - setRemoting(DataModel.count, { + setRemoting(DataModel, 'count', { description: 'Count instances of the model matched by where from the data source', accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, returns: {arg: 'count', type: 'number'}, http: {verb: 'get', path: '/count'} }); - setRemoting(DataModel.prototype.updateAttributes, { + setRemoting(DataModel.prototype, 'updateAttributes', { description: 'Update attributes for a model instance and persist it into the data source', accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, returns: {arg: 'data', type: typeName, root: true}, @@ -500,7 +515,7 @@ DataModel.setupRemoting = function() { }); if(options.trackChanges) { - setRemoting(DataModel.diff, { + setRemoting(DataModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accepts: [ {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, @@ -511,7 +526,7 @@ DataModel.setupRemoting = function() { http: {verb: 'post', path: '/diff'} }); - setRemoting(DataModel.changes, { + setRemoting(DataModel, 'changes', { description: 'Get the changes to a model since a given checkpoint.' + 'Provide a filter object to reduce the number of results returned.', accepts: [ @@ -522,37 +537,37 @@ DataModel.setupRemoting = function() { http: {verb: 'get', path: '/changes'} }); - setRemoting(DataModel.checkpoint, { + setRemoting(DataModel, 'checkpoint', { description: 'Create a checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); - setRemoting(DataModel.currentCheckpoint, { + setRemoting(DataModel, 'currentCheckpoint', { description: 'Get the current checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'get', path: '/checkpoint'} }); - setRemoting(DataModel.createUpdates, { + setRemoting(DataModel, 'createUpdates', { description: 'Create an update list from a delta list', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} }); - setRemoting(DataModel.bulkUpdate, { + setRemoting(DataModel, 'bulkUpdate', { description: 'Run multiple updates at once. Note: this is not atomic.', accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); - setRemoting(DataModel.rectifyAllChanges, { + setRemoting(DataModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', http: {verb: 'post', path: '/rectify-all'} }); - setRemoting(DataModel.rectifyChange, { + setRemoting(DataModel, 'rectifyChange', { description: 'Tell loopback that a change to the model with the given id has occurred.', accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, http: {verb: 'post', path: '/:id/rectify-change'} @@ -889,8 +904,6 @@ DataModel.getSourceId = function(cb) { */ DataModel.enableChangeTracking = function() { - // console.log('THIS SHOULD NOT RUN ON A MODEL CONNECTED TO A REMOTE DATASOURCE'); - var Model = this; var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; diff --git a/test/change.test.js b/test/change.test.js index d570f5096..7b2452c51 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -6,12 +6,12 @@ describe('Change', function(){ var memory = loopback.createDataSource({ connector: loopback.Memory }); - Change = loopback.Change.extend('change'); - Change.attachTo(memory); - - TestModel = loopback.DataModel.extend('chtest'); + TestModel = loopback.DataModel.extend('chtest', {}, { + trackChanges: true + }); this.modelName = TestModel.modelName; TestModel.attachTo(memory); + Change = TestModel.getChangeModel(); }); beforeEach(function(done) { @@ -46,16 +46,16 @@ describe('Change', function(){ describe('using an existing untracked model', function () { beforeEach(function(done) { var test = this; - Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trakedChagnes) { + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { if(err) return done(err); - test.trakedChagnes = trakedChagnes; + test.trackedChanges = trackedChanges; done(); }); }); it('should create an entry', function () { - assert(Array.isArray(this.trakedChagnes)); - assert.equal(this.trakedChagnes[0].modelId, this.modelId); + assert(Array.isArray(this.trackedChanges)); + assert.equal(this.trackedChanges[0].modelId, this.modelId); }); it('should only create one change', function (done) { diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 462d04260..608b3d7e9 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,7 +3,7 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; -var explorer = require('loopback-explorer'); +// var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; @@ -13,7 +13,7 @@ TestModel.attachTo(loopback.memory()); app.model(TestModel); app.model(TestModel.getChangeModel()); -app.use('/explorer', explorer(app, {basePath: apiPath})); +// app.use('/explorer', explorer(app, {basePath: apiPath})); app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); diff --git a/test/model.test.js b/test/model.test.js index 28753e29b..5499f0d5c 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -479,7 +479,7 @@ describe.onServer('Remote Methods', function(){ var result; var current; - async.parallel(tasks, function(err) { + async.series(tasks, function(err) { if(err) return done(err); assert.equal(result, current + 1); @@ -495,88 +495,13 @@ describe.onServer('Remote Methods', function(){ function checkpoint(cb) { User.checkpoint(function(err, cp) { - result = cp.id; + result = cp.seq; cb(err); }); } }); }); - describe('Replication / Change APIs', function() { - beforeEach(function(done) { - var test = this; - this.dataSource = dataSource; - var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { - trackChanges: true - }); - SourceModel.attachTo(dataSource); - - var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { - trackChanges: true - }); - TargetModel.attachTo(dataSource); - - var createOne = SourceModel.create.bind(SourceModel, { - name: 'baz' - }); - - async.parallel([ - createOne, - function(cb) { - SourceModel.currentCheckpoint(function(err, id) { - if(err) return cb(err); - test.startingCheckpoint = id; - cb(); - }); - } - ], process.nextTick.bind(process, done)); - }); - - describe('Model.changes(since, filter, callback)', function() { - it('Get changes since the given checkpoint', function (done) { - this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { - assert.equal(changes.length, 1); - done(); - }); - }); - }); - - describe.skip('Model.replicate(since, targetModel, options, callback)', function() { - it('Replicate data using the target model', function (done) { - var test = this; - var options = {}; - var sourceData; - var targetData; - - this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, - options, function(err, conflicts) { - assert(conflicts.length === 0); - async.parallel([ - function(cb) { - test.SourceModel.find(function(err, result) { - if(err) return cb(err); - sourceData = result; - cb(); - }); - }, - function(cb) { - test.TargetModel.find(function(err, result) { - if(err) return cb(err); - targetData = result; - cb(); - }); - } - ], function(err) { - if(err) return done(err); - - assert.deepEqual(sourceData, targetData); - done(); - }); - }); - }); - }); - }); - describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 5871299b6..129525b83 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -62,7 +62,6 @@ describe('RemoteConnector', function() { ServerModel.setupRemoting(); var m = new RemoteModel({foo: 'bar'}); - console.log(m.save.toString()); m.save(function(err, inst) { assert(inst instanceof RemoteModel); assert(calledServerCreate); diff --git a/test/replication.test.js b/test/replication.test.js new file mode 100644 index 000000000..bda692b34 --- /dev/null +++ b/test/replication.test.js @@ -0,0 +1,343 @@ +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var DataModel = loopback.DataModel; + +describe('Replication / Change APIs', function() { + beforeEach(function() { + var test = this; + var dataSource = this.dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { + trackChanges: true + }); + SourceModel.attachTo(dataSource); + + var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { + trackChanges: true + }); + TargetModel.attachTo(dataSource); + + this.createInitalData = function(cb) { + SourceModel.create({name: 'foo'}, function(err, inst) { + if(err) return cb(err); + test.model = inst; + + // give loopback a chance to register the change + // TODO(ritch) get rid of this... + setTimeout(function() { + SourceModel.replicate(TargetModel, cb); + }, 100); + }); + }; + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + var test = this; + this.SourceModel.create({name: 'foo'}, function(err) { + if(err) return done(err); + setTimeout(function() { + test.SourceModel.changes(test.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }, 1); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.create({name: 'foo'}, function(err) { + setTimeout(replicate, 100); + }); + + function replicate() { + test.SourceModel.replicate(test.startingCheckpoint, test.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + } + }); + }); + + describe('conflict detection - both updated', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict(err, conflicts) { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be UPDATE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.UPDATE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - source deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.DELETE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(source, null); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - target deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.DELETE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(target, null); + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + done(); + }); + }); + }); + + describe('conflict detection - both deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should not detect a conflict', function() { + assert.equal(this.conflicts.length, 0); + assert(!this.conflict); + }); + }); +}); \ No newline at end of file From 2f21f4ec1e41c3555e38cfd4a678a3916daaa0bf Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 15 May 2014 17:27:02 -0700 Subject: [PATCH 27/74] Rework replication test --- .gitignore | 1 + lib/models/change.js | 24 +-- lib/models/data-model.js | 75 +++++--- test/change.test.js | 16 +- test/fixtures/e2e/app.js | 4 +- test/model.test.js | 79 +------- test/remote-connector.test.js | 1 - test/replication.test.js | 343 ++++++++++++++++++++++++++++++++++ 8 files changed, 412 insertions(+), 131 deletions(-) create mode 100644 test/replication.test.js diff --git a/.gitignore b/.gitignore index a84a7659a..fdcae0c86 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ *.swp *.swo node_modules +dist diff --git a/lib/models/change.js b/lib/models/change.js index c55db2d95..22d0b2c60 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -195,7 +195,8 @@ Change.prototype.rectify = function(cb) { Change.prototype.currentRevision = function(cb) { var model = this.getModelCtor(); - model.findById(this.modelId, function(err, inst) { + var id = this.getModelId(); + model.findById(id, function(err, inst) { if(err) return Change.handleError(err, cb); if(inst) { cb(null, Change.revisionForInst(inst)); @@ -254,16 +255,6 @@ Change.prototype.type = function() { 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 loopback.getModel(this.modelName); -} - /** * Compare two changes. * @param {Change} change @@ -403,9 +394,18 @@ Change.handleError = function(err) { } } +/** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + +Change.prototype.getModelCtor = function() { + return this.constructor.settings.trackModel; +} + Change.prototype.getModelId = function() { // TODO(ritch) get rid of the need to create an instance - var Model = this.constructor.settings.trackModel; + var Model = this.getModelCtor(); var id = this.modelId; var m = new Model(); m.setId(id); diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 8295d6dd3..5fbe1d4ef 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -62,16 +62,31 @@ DataModel.setup = function setupDataModel() { * @private */ -function setRemoting(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } +function setRemoting(target, name, options) { + var fn = target[name]; + setupFunction(fn, options); + target[name] = createProxy(fn, options); +} + +function createProxy(fn, options) { + var p = function proxy() { + return fn.apply(this, arguments); } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; + + return setupFunction(fn, options); +} + +function setupFunction(fn, options) { + options = options || {}; + for (var opt in options) { + if (options.hasOwnProperty(opt)) { + fn[opt] = options[opt]; + } + } + fn.shared = true; + // allow connectors to override the function by marking as delegate + fn._delegate = true; + return fn; } /*! @@ -425,28 +440,28 @@ DataModel.setupRemoting = function() { // TODO(ritch) setRemoting should create its own function... - setRemoting(DataModel.create, { + setRemoting(DataModel, 'create', { description: 'Create a new instance of the model and persist it into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'post', path: '/'} }); - setRemoting(DataModel.upsert, { + setRemoting(DataModel, 'upsert', { description: 'Update an existing model instance or insert a new one into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'put', path: '/'} }); - setRemoting(DataModel.exists, { + setRemoting(DataModel, 'exists', { description: 'Check whether a model instance exists in the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, returns: {arg: 'exists', type: 'boolean'}, http: {verb: 'get', path: '/:id/exists'} }); - setRemoting(DataModel.findById, { + setRemoting(DataModel, 'findById', { description: 'Find a model instance by id from the data source', accepts: { arg: 'id', type: 'any', description: 'Model id', required: true, @@ -457,42 +472,42 @@ DataModel.setupRemoting = function() { rest: {after: convertNullToNotFoundError} }); - setRemoting(DataModel.find, { + setRemoting(DataModel, 'find', { description: 'Find all instances of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: [typeName], root: true}, http: {verb: 'get', path: '/'} }); - setRemoting(DataModel.findOne, { + setRemoting(DataModel, 'findOne', { description: 'Find first instance of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/findOne'} }); - setRemoting(DataModel.destroyAll, { + setRemoting(DataModel, 'destroyAll', { description: 'Delete all matching records', accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, - http: {verb: 'del', path: '/'} + http: {verb: 'del', path: '/'}, + shared: false }); - DataModel.destroyAll.shared = false; - setRemoting(DataModel.deleteById, { + setRemoting(DataModel, 'removeById', { description: 'Delete a model instance by id from the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, http: {source: 'path'}}, http: {verb: 'del', path: '/:id'} }); - setRemoting(DataModel.count, { + setRemoting(DataModel, 'count', { description: 'Count instances of the model matched by where from the data source', accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, returns: {arg: 'count', type: 'number'}, http: {verb: 'get', path: '/count'} }); - setRemoting(DataModel.prototype.updateAttributes, { + setRemoting(DataModel.prototype, 'updateAttributes', { description: 'Update attributes for a model instance and persist it into the data source', accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, returns: {arg: 'data', type: typeName, root: true}, @@ -500,7 +515,7 @@ DataModel.setupRemoting = function() { }); if(options.trackChanges) { - setRemoting(DataModel.diff, { + setRemoting(DataModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accepts: [ {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, @@ -511,7 +526,7 @@ DataModel.setupRemoting = function() { http: {verb: 'post', path: '/diff'} }); - setRemoting(DataModel.changes, { + setRemoting(DataModel, 'changes', { description: 'Get the changes to a model since a given checkpoint.' + 'Provide a filter object to reduce the number of results returned.', accepts: [ @@ -522,37 +537,37 @@ DataModel.setupRemoting = function() { http: {verb: 'get', path: '/changes'} }); - setRemoting(DataModel.checkpoint, { + setRemoting(DataModel, 'checkpoint', { description: 'Create a checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); - setRemoting(DataModel.currentCheckpoint, { + setRemoting(DataModel, 'currentCheckpoint', { description: 'Get the current checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'get', path: '/checkpoint'} }); - setRemoting(DataModel.createUpdates, { + setRemoting(DataModel, 'createUpdates', { description: 'Create an update list from a delta list', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} }); - setRemoting(DataModel.bulkUpdate, { + setRemoting(DataModel, 'bulkUpdate', { description: 'Run multiple updates at once. Note: this is not atomic.', accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); - setRemoting(DataModel.rectifyAllChanges, { + setRemoting(DataModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', http: {verb: 'post', path: '/rectify-all'} }); - setRemoting(DataModel.rectifyChange, { + setRemoting(DataModel, 'rectifyChange', { description: 'Tell loopback that a change to the model with the given id has occurred.', accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, http: {verb: 'post', path: '/:id/rectify-change'} @@ -889,8 +904,6 @@ DataModel.getSourceId = function(cb) { */ DataModel.enableChangeTracking = function() { - // console.log('THIS SHOULD NOT RUN ON A MODEL CONNECTED TO A REMOTE DATASOURCE'); - var Model = this; var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; diff --git a/test/change.test.js b/test/change.test.js index d570f5096..7b2452c51 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -6,12 +6,12 @@ describe('Change', function(){ var memory = loopback.createDataSource({ connector: loopback.Memory }); - Change = loopback.Change.extend('change'); - Change.attachTo(memory); - - TestModel = loopback.DataModel.extend('chtest'); + TestModel = loopback.DataModel.extend('chtest', {}, { + trackChanges: true + }); this.modelName = TestModel.modelName; TestModel.attachTo(memory); + Change = TestModel.getChangeModel(); }); beforeEach(function(done) { @@ -46,16 +46,16 @@ describe('Change', function(){ describe('using an existing untracked model', function () { beforeEach(function(done) { var test = this; - Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trakedChagnes) { + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { if(err) return done(err); - test.trakedChagnes = trakedChagnes; + test.trackedChanges = trackedChanges; done(); }); }); it('should create an entry', function () { - assert(Array.isArray(this.trakedChagnes)); - assert.equal(this.trakedChagnes[0].modelId, this.modelId); + assert(Array.isArray(this.trackedChanges)); + assert.equal(this.trackedChanges[0].modelId, this.modelId); }); it('should only create one change', function (done) { diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 462d04260..608b3d7e9 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,7 +3,7 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; -var explorer = require('loopback-explorer'); +// var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; @@ -13,7 +13,7 @@ TestModel.attachTo(loopback.memory()); app.model(TestModel); app.model(TestModel.getChangeModel()); -app.use('/explorer', explorer(app, {basePath: apiPath})); +// app.use('/explorer', explorer(app, {basePath: apiPath})); app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); diff --git a/test/model.test.js b/test/model.test.js index 28753e29b..5499f0d5c 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -479,7 +479,7 @@ describe.onServer('Remote Methods', function(){ var result; var current; - async.parallel(tasks, function(err) { + async.series(tasks, function(err) { if(err) return done(err); assert.equal(result, current + 1); @@ -495,88 +495,13 @@ describe.onServer('Remote Methods', function(){ function checkpoint(cb) { User.checkpoint(function(err, cp) { - result = cp.id; + result = cp.seq; cb(err); }); } }); }); - describe('Replication / Change APIs', function() { - beforeEach(function(done) { - var test = this; - this.dataSource = dataSource; - var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { - trackChanges: true - }); - SourceModel.attachTo(dataSource); - - var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { - trackChanges: true - }); - TargetModel.attachTo(dataSource); - - var createOne = SourceModel.create.bind(SourceModel, { - name: 'baz' - }); - - async.parallel([ - createOne, - function(cb) { - SourceModel.currentCheckpoint(function(err, id) { - if(err) return cb(err); - test.startingCheckpoint = id; - cb(); - }); - } - ], process.nextTick.bind(process, done)); - }); - - describe('Model.changes(since, filter, callback)', function() { - it('Get changes since the given checkpoint', function (done) { - this.SourceModel.changes(this.startingCheckpoint, {}, function(err, changes) { - assert.equal(changes.length, 1); - done(); - }); - }); - }); - - describe.skip('Model.replicate(since, targetModel, options, callback)', function() { - it('Replicate data using the target model', function (done) { - var test = this; - var options = {}; - var sourceData; - var targetData; - - this.SourceModel.replicate(this.startingCheckpoint, this.TargetModel, - options, function(err, conflicts) { - assert(conflicts.length === 0); - async.parallel([ - function(cb) { - test.SourceModel.find(function(err, result) { - if(err) return cb(err); - sourceData = result; - cb(); - }); - }, - function(cb) { - test.TargetModel.find(function(err, result) { - if(err) return cb(err); - targetData = result; - cb(); - }); - } - ], function(err) { - if(err) return done(err); - - assert.deepEqual(sourceData, targetData); - done(); - }); - }); - }); - }); - }); - describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 5871299b6..129525b83 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -62,7 +62,6 @@ describe('RemoteConnector', function() { ServerModel.setupRemoting(); var m = new RemoteModel({foo: 'bar'}); - console.log(m.save.toString()); m.save(function(err, inst) { assert(inst instanceof RemoteModel); assert(calledServerCreate); diff --git a/test/replication.test.js b/test/replication.test.js new file mode 100644 index 000000000..bda692b34 --- /dev/null +++ b/test/replication.test.js @@ -0,0 +1,343 @@ +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var DataModel = loopback.DataModel; + +describe('Replication / Change APIs', function() { + beforeEach(function() { + var test = this; + var dataSource = this.dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { + trackChanges: true + }); + SourceModel.attachTo(dataSource); + + var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { + trackChanges: true + }); + TargetModel.attachTo(dataSource); + + this.createInitalData = function(cb) { + SourceModel.create({name: 'foo'}, function(err, inst) { + if(err) return cb(err); + test.model = inst; + + // give loopback a chance to register the change + // TODO(ritch) get rid of this... + setTimeout(function() { + SourceModel.replicate(TargetModel, cb); + }, 100); + }); + }; + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + var test = this; + this.SourceModel.create({name: 'foo'}, function(err) { + if(err) return done(err); + setTimeout(function() { + test.SourceModel.changes(test.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }, 1); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.create({name: 'foo'}, function(err) { + setTimeout(replicate, 100); + }); + + function replicate() { + test.SourceModel.replicate(test.startingCheckpoint, test.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + } + }); + }); + + describe('conflict detection - both updated', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict(err, conflicts) { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be UPDATE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.UPDATE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - source deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.DELETE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(source, null); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - target deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.DELETE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(target, null); + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + done(); + }); + }); + }); + + describe('conflict detection - both deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should not detect a conflict', function() { + assert.equal(this.conflicts.length, 0); + assert(!this.conflict); + }); + }); +}); \ No newline at end of file From 4d5e7884c244ef9a8b6f0d338cad3afde95009dc Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 16 May 2014 09:58:23 -0700 Subject: [PATCH 28/74] Add test for conflicts where both deleted --- test/change.test.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/change.test.js b/test/change.test.js index 7b2452c51..99338ed5e 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -178,7 +178,7 @@ describe('Change', function(){ }); }); - describe('Change.type()', function () { + describe('change.type()', function () { it('CREATE', function () { var change = new Change({ rev: this.revisionForModel @@ -226,6 +226,24 @@ describe('Change', function(){ assert.equal(change.equals(otherChange), true); }); + + it('should return true when both changes are deletes', function () { + var REV = 'foo'; + var change = new Change({ + rev: null, + prev: REV, + }); + + var otherChange = new Change({ + rev: undefined, + prev: REV + }); + + assert.equal(change.type(), Change.DELETE); + assert.equal(otherChange.type(), Change.DELETE); + + assert.equal(change.equals(otherChange), true); + }); }); describe('change.isBasedOn(otherChange)', function () { From 558ea60da02a91100369cf9529a5cea104d76d6e Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Fri, 16 May 2014 12:32:54 -0700 Subject: [PATCH 29/74] In progress: rework remoting meta-data --- lib/models/data-model.js | 45 +++++++--------------------------------- lib/models/model.js | 21 +++++++++++++++++-- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 5fbe1d4ef..77f041ad9 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -51,42 +51,9 @@ DataModel.setup = function setupDataModel() { DataModel.enableChangeTracking(); }); } - - DataModel.setupRemoting(); -} - -/*! - * Configure the remoting attributes for a given function - * @param {Function} fn The function - * @param {Object} options The options - * @private - */ - -function setRemoting(target, name, options) { - var fn = target[name]; - setupFunction(fn, options); - target[name] = createProxy(fn, options); -} - -function createProxy(fn, options) { - var p = function proxy() { - return fn.apply(this, arguments); - } - - return setupFunction(fn, options); -} - -function setupFunction(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } - } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; - return fn; + DataModel.on('dataSourceAttached', function() { + DataModel.setupRemoting(); + }); } /*! @@ -438,7 +405,11 @@ DataModel.setupRemoting = function() { var typeName = DataModel.modelName; var options = DataModel.settings; - // TODO(ritch) setRemoting should create its own function... + function setRemoting(target, name, options) { + var fn = target[name]; + options.prototype = target === DataModel.prototype; + DataModel.remoteMethod(name, options); + } setRemoting(DataModel, 'create', { description: 'Create a new instance of the model and persist it into the data source', diff --git a/lib/models/model.js b/lib/models/model.js index 310a1e829..8065766b0 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -82,8 +82,6 @@ var _ = require('underscore'); var Model = module.exports = modeler.define('Model'); -Model.shared = true; - /*! * Called when a model is extended. */ @@ -92,6 +90,8 @@ Model.setup = function () { var ModelCtor = this; var options = this.settings; + ModelCtor.sharedClass = new SharedClass(remoteNamespace, ModelCtor); + ModelCtor.sharedCtor = function (data, id, fn) { if(typeof data === 'function') { fn = data; @@ -290,5 +290,22 @@ Model.getApp = function(callback) { } } +/** + * Enable remote invocation for the method with the given name. + * + * @param {String} name The name of the method. + * ```js + * // static method example (eg. Model.myMethod()) + * Model.remoteMethod('myMethod'); + * // instance method exampe (eg. Model.prototype.myMethod()) + * Model.remoteMethod('prototype.myMethod', {prototype: true}); + * @param {Object} options The remoting options. + * See [loopback.remoteMethod()](http://docs.strongloop.com/display/DOC/Remote+methods+and+hooks#Remotemethodsandhooks-loopback.remoteMethod(fn,[options])) for details. + */ + +Model.remoteMethod = function(name, options) { + this.sharedClass.defineMethod(name, options); +} + // setup the initial model Model.setup(); From eec7bdd5f4497150973a0359b67fbc1b65624445 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 19 May 2014 15:56:26 -0700 Subject: [PATCH 30/74] - Use the RemoteObjects class to find remote objects instead of creating a cache - Use the SharedClass class to build the remote connector - Change default base model from Model to DataModel - Fix DataModel errors not logging correct method names - Use the strong-remoting 1.4 resolver API to resolve dynamic remote methods (relation api) - Remove use of fn object for storing remoting meta data --- CHANGES.md | 35 +++++++++++++ lib/application.js | 15 +++--- lib/connectors/remote.js | 49 +++++++----------- lib/loopback.js | 2 +- lib/models/change.js | 4 +- lib/models/data-model.js | 18 +++---- lib/models/model.js | 93 +++++++++++++++++++++++++++++------ package.json | 2 +- test/app.test.js | 6 ++- test/change.test.js | 16 +++--- test/data-source.test.js | 18 ++++--- test/model.test.js | 6 +-- test/relations.integration.js | 1 - test/replication.test.js | 2 +- test/util/model-tests.js | 4 +- 15 files changed, 176 insertions(+), 95 deletions(-) create mode 100644 CHANGES.md diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 000000000..d8551ddb3 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,35 @@ +# Breaking Changes + +# 1.9 + +## Remote Method API + +`loopback.remoteMethod()` is now deprecated. + +Defining remote methods now should be done like this: + +```js +// static +MyModel.greet = function(msg, cb) { + cb(null, 'greetings... ' + msg); +} + +MyModel.remoteMethod( + 'greet', + { + accepts: [{arg: 'msg', type: 'string'}], + returns: {arg: 'greeting', type: 'string'} + } +); +``` + +**NOTE: remote instance method support is also now deprecated... +Use static methods instead. If you absolutely need it you can still set +`options.isStatic = false`** We plan to drop support for instance methods in +`2.0`. + +## Remote Instance Methods + +All remote instance methods have been replaced with static replacements. + +The REST API is backwards compatible. diff --git a/lib/application.js b/lib/application.js index 6e71ce7c5..d14d6db1f 100644 --- a/lib/application.js +++ b/lib/application.js @@ -108,7 +108,9 @@ app.model = function (Model, config) { assert(typeof Model === 'function', 'app.model(Model) => Model must be a function / constructor'); assert(Model.modelName, 'Model must have a "modelName" property'); var remotingClassName = compat.getClassNameForRemoting(Model); - this.remotes().exports[remotingClassName] = Model; + if(Model.sharedClass) { + this.remotes().addClass(Model.sharedClass); + } this.models().push(Model); clearHandlerCache(this); Model.shared = true; @@ -205,14 +207,9 @@ app.dataSource = function (name, config) { app.remoteObjects = function () { var result = {}; - var models = this.models(); - - // add in models - models.forEach(function (ModelCtor) { - // only add shared models - if(ModelCtor.shared && typeof ModelCtor.sharedCtor === 'function') { - result[compat.getClassNameForRemoting(ModelCtor)] = ModelCtor; - } + + this.remotes().classes().forEach(function(sharedClass) { + result[sharedClass.name] = sharedClass.ctor; }); return result; diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index bbbedb1ac..31b932733 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -52,53 +52,40 @@ RemoteConnector.initialize = function(dataSource, callback) { RemoteConnector.prototype.define = function(definition) { var Model = definition.model; - var className = compat.getClassNameForRemoting(Model); - var remotes = this.remotes + var remotes = this.remotes; var SharedClass; - var classes; - var i = 0; - remotes.exports[className] = Model; - - classes = remotes.classes(); - - for(; i < classes.length; i++) { - SharedClass = classes[i]; - if(SharedClass.name === className) { - SharedClass - .methods() - .forEach(function(remoteMethod) { - // TODO(ritch) more elegant way of ignoring a nested shared class - if(remoteMethod.name !== 'Change' - && remoteMethod.name !== 'Checkpoint') { - createProxyMethod(Model, remotes, remoteMethod); - } - }); - - return; - } - } + assert(Model.sharedClass, 'cannot attach ' + Model.modelName + + ' to a remote connector without a Model.sharedClass'); + + remotes.addClass(Model.sharedClass); + + Model + .sharedClass + .methods() + .forEach(function(remoteMethod) { + // TODO(ritch) more elegant way of ignoring a nested shared class + if(remoteMethod.name !== 'Change' + && remoteMethod.name !== 'Checkpoint') { + createProxyMethod(Model, remotes, remoteMethod); + } + }); } function createProxyMethod(Model, remotes, remoteMethod) { var scope = remoteMethod.isStatic ? Model : Model.prototype; var original = scope[remoteMethod.name]; - var fn = scope[remoteMethod.name] = function remoteMethodProxy() { + scope[remoteMethod.name] = function remoteMethodProxy() { var args = Array.prototype.slice.call(arguments); var lastArgIsFunc = typeof args[args.length - 1] === 'function'; var callback; if(lastArgIsFunc) { callback = args.pop(); } - + remotes.invoke(remoteMethod.stringName, args, callback); } - - for(var key in original) { - fn[key] = original[key]; - } - fn._delegate = true; } function noop() {} diff --git a/lib/loopback.js b/lib/loopback.js index d72b95af1..3a0277132 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -155,7 +155,7 @@ loopback.createModel = function (name, properties, options) { BaseModel = loopback.getModel(BaseModel); } - BaseModel = BaseModel || loopback.Model; + BaseModel = BaseModel || loopback.DataModel; var model = BaseModel.extend(name, properties, options); diff --git a/lib/models/change.js b/lib/models/change.js index 22d0b2c60..9a8286fd9 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -94,7 +94,7 @@ Change.rectifyModelChanges = function(modelName, modelIds, callback) { modelIds.forEach(function(id) { tasks.push(function(cb) { - Change.findOrCreate(modelName, id, function(err, change) { + Change.findOrCreateChange(modelName, id, function(err, change) { if(err) return Change.handleError(err, cb); change.rectify(cb); }); @@ -126,7 +126,7 @@ Change.idForModel = function(modelName, modelId) { * @end */ -Change.findOrCreate = function(modelName, modelId, callback) { +Change.findOrCreateChange = function(modelName, modelId, callback) { assert(loopback.getModel(modelName), modelName + ' does not exist'); var id = this.idForModel(modelName, modelId); var Change = this; diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 77f041ad9..5661a7904 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -51,9 +51,8 @@ DataModel.setup = function setupDataModel() { DataModel.enableChangeTracking(); }); } - DataModel.on('dataSourceAttached', function() { - DataModel.setupRemoting(); - }); + + DataModel.setupRemoting(); } /*! @@ -107,7 +106,7 @@ DataModel.create = function (data, callback) { */ DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { - throwNotAttached(this.modelName, 'updateOrCreate'); + throwNotAttached(this.modelName, 'upsert'); }; /** @@ -142,7 +141,7 @@ DataModel.exists = function exists(id, cb) { */ DataModel.findById = function find(id, cb) { - throwNotAttached(this.modelName, 'find'); + throwNotAttached(this.modelName, 'findById'); }; /** @@ -190,9 +189,6 @@ DataModel.destroyAll = function destroyAll(where, cb) { throwNotAttached(this.modelName, 'destroyAll'); }; -// disable remoting by default -DataModel.destroyAll.shared = false; - /** * Destroy a record by id * @param {*} id The id value @@ -405,9 +401,9 @@ DataModel.setupRemoting = function() { var typeName = DataModel.modelName; var options = DataModel.settings; - function setRemoting(target, name, options) { - var fn = target[name]; - options.prototype = target === DataModel.prototype; + function setRemoting(scope, name, options) { + var fn = scope[name]; + options.isStatic = scope === DataModel; DataModel.remoteMethod(name, options); } diff --git a/lib/models/model.js b/lib/models/model.js index 8065766b0..374cd43ef 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -10,6 +10,7 @@ var modeler = new ModelBuilder(); var async = require('async'); var assert = require('assert'); var _ = require('underscore'); +var SharedClass = require('strong-remoting').SharedClass; /** * The base class for **all models**. @@ -89,10 +90,18 @@ var Model = module.exports = modeler.define('Model'); Model.setup = function () { var ModelCtor = this; var options = this.settings; - - ModelCtor.sharedClass = new SharedClass(remoteNamespace, ModelCtor); + // create a sharedClass + var sharedClass = ModelCtor.sharedClass = new SharedClass( + compat.getClassNameForRemoting(ModelCtor), + ModelCtor, + options.remoting + ); + + // support remoting prototype methods ModelCtor.sharedCtor = function (data, id, fn) { + var ModelCtor = this; + if(typeof data === 'function') { fn = data; data = null; @@ -132,6 +141,18 @@ Model.setup = function () { } } + var idDesc = ModelCtor.modelName + ' id'; + ModelCtor.sharedCtor.accepts = [ + {arg: 'id', type: 'any', http: {source: 'path'}, description: idDesc} + // {arg: 'instance', type: 'object', http: {source: 'body'}} + ]; + + ModelCtor.sharedCtor.http = [ + {path: '/:id'} + ]; + + ModelCtor.sharedCtor.returns = {root: true}; + // before remote hook ModelCtor.beforeRemote = function (name, fn) { var self = this; @@ -166,18 +187,20 @@ Model.setup = function () { } }; - // Map the prototype method to /:id with data in the body - var idDesc = ModelCtor.modelName + ' id'; - ModelCtor.sharedCtor.accepts = [ - {arg: 'id', type: 'any', http: {source: 'path'}, description: idDesc} - // {arg: 'instance', type: 'object', http: {source: 'body'}} - ]; - - ModelCtor.sharedCtor.http = [ - {path: '/:id'} - ]; - - ModelCtor.sharedCtor.returns = {root: true}; + // resolve relation functions + sharedClass.resolve(function resolver(define) { + var relations = ModelCtor.relations; + if(!relations) return; + // get the relations + for(var relationName in relations) { + var relation = relations[relationName]; + if(relation.type === 'belongsTo') { + ModelCtor.belongsToRemoting(relationName, relation, define) + } else { + ModelCtor.scopeRemoting(relationName, relation, define); + } + } + }); return ModelCtor; }; @@ -297,15 +320,53 @@ Model.getApp = function(callback) { * ```js * // static method example (eg. Model.myMethod()) * Model.remoteMethod('myMethod'); - * // instance method exampe (eg. Model.prototype.myMethod()) - * Model.remoteMethod('prototype.myMethod', {prototype: true}); * @param {Object} options The remoting options. * See [loopback.remoteMethod()](http://docs.strongloop.com/display/DOC/Remote+methods+and+hooks#Remotemethodsandhooks-loopback.remoteMethod(fn,[options])) for details. */ Model.remoteMethod = function(name, options) { + if(options.isStatic === undefined) { + options.isStatic = true; + } this.sharedClass.defineMethod(name, options); } +Model.belongsToRemoting = function(relationName, relation, define) { + var fn = this.prototype[relationName]; + define(relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + relationName}, + accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + description: 'Fetches belongsTo relation ' + relationName, + returns: {arg: relationName, type: relation.modelTo.modelName, root: true} + }, fn); +} + +Model.scopeRemoting = function(relationName, relation, define) { + var toModelName = relation.modelTo.modelName; + + define('__get__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + relationName}, + accepts: {arg: 'filter', type: 'object'}, + description: 'Queries ' + relationName + ' of ' + this.modelName + '.', + returns: {arg: relationName, type: [toModelName], root: true} + }); + + define('__create__' + relationName, { + isStatic: false, + http: {verb: 'post', path: '/' + relationName}, + accepts: {arg: 'data', type: 'object', http: {source: 'body'}}, + description: 'Creates a new instance in ' + relationName + ' of this model.', + returns: {arg: 'data', type: toModelName, root: true} + }); + + define('__delete__' + relationName, { + isStatic: false, + http: {verb: 'delete', path: '/' + relationName}, + description: 'Deletes all ' + relationName + ' of this model.' + }); +} + // setup the initial model Model.setup(); diff --git a/package.json b/package.json index 5f9baed94..c5bba969c 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "dependencies": { "debug": "~0.7.4", "express": "~3.4.8", - "strong-remoting": "~1.3.1", + "strong-remoting": "~1.4.0", "inflection": "~1.3.5", "passport": "~0.2.0", "passport-local": "~0.1.6", diff --git a/test/app.test.js b/test/app.test.js index 3aae923e7..41d6285cf 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -31,7 +31,8 @@ describe('app', function() { var Color = DataModel.extend('color', {name: String}); app.model(Color); Color.attachTo(db); - expect(app.remotes().exports).to.eql({ color: Color }); + var classes = app.remotes().classes().map(function(c) {return c.name}); + expect(classes).to.contain('color'); }); it('updates REST API when a new model is added', function(done) { @@ -56,7 +57,8 @@ describe('app', function() { it('uses plural name as shared class name', function() { var Color = db.createModel('color', {name: String}); app.model(Color); - expect(app.remotes().exports).to.eql({ colors: Color }); + var classes = app.remotes().classes().map(function(c) {return c.name}); + expect(classes).to.contain('colors'); }); it('uses plural name as app.remoteObjects() key', function() { diff --git a/test/change.test.js b/test/change.test.js index 99338ed5e..e0c53eeba 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -48,14 +48,16 @@ describe('Change', function(){ var test = this; Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { if(err) return done(err); - test.trackedChanges = trackedChanges; done(); }); }); - it('should create an entry', function () { - assert(Array.isArray(this.trackedChanges)); - assert.equal(this.trackedChanges[0].modelId, this.modelId); + it('should create an entry', function (done) { + var test = this; + Change.find(function(err, trackedChanges) { + assert.equal(trackedChanges[0].modelId, test.modelId.toString()); + done(); + }); }); it('should only create one change', function (done) { @@ -67,12 +69,12 @@ describe('Change', function(){ }); }); - describe('Change.findOrCreate(modelName, modelId, callback)', function () { + describe('Change.findOrCreateChange(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) { + Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { if(err) return done(err); test.result = result; done(); @@ -102,7 +104,7 @@ describe('Change', function(){ beforeEach(function(done) { var test = this; - Change.findOrCreate(this.modelName, this.modelId, function(err, result) { + Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { if(err) return done(err); test.result = result; done(); diff --git a/test/data-source.test.js b/test/data-source.test.js index ee29b3b1e..552da12dd 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -36,7 +36,7 @@ describe('DataSource', function() { }); }); - describe('DataModel Methods', function() { + describe.skip('DataModel Methods', function() { it("List the enabled and disabled methods", function() { var TestModel = loopback.DataModel.extend('TestDataModel'); TestModel.attachTo(loopback.memory()); @@ -61,16 +61,18 @@ describe('DataSource', function() { existsAndShared(TestModel, 'belongsTo', false); existsAndShared(TestModel, 'hasAndBelongsToMany', false); // existsAndShared(TestModel.prototype, 'updateAttributes', true); - existsAndShared(TestModel.prototype, 'save', false); - existsAndShared(TestModel.prototype, 'isNewRecord', false); - existsAndShared(TestModel.prototype, '_adapter', false); - existsAndShared(TestModel.prototype, 'destroy', false); - existsAndShared(TestModel.prototype, 'reload', false); + existsAndShared(TestModel, 'save', false, true); + existsAndShared(TestModel, 'isNewRecord', false, true); + existsAndShared(TestModel, '_adapter', false, true); + existsAndShared(TestModel, 'destroy', false, true); + existsAndShared(TestModel, 'reload', false, true); - function existsAndShared(scope, name, isRemoteEnabled) { + function existsAndShared(Model, name, isRemoteEnabled, isProto) { + var scope = isProto ? Model.prototype : Model; var fn = scope[name]; + var actuallyEnabled = Model.getRemoteMethod(name); assert(fn, name + ' should be defined!'); - assert(!!fn.shared === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); + assert(actuallyEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); } }); }); diff --git a/test/model.test.js b/test/model.test.js index 5499f0d5c..b00f41de3 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -53,11 +53,11 @@ describe('Model / DataModel', function() { connector: loopback.Memory }); - assert(MyModel.find === undefined, 'should not have data access methods'); - MyModel.attachTo(dataSource); - assert(typeof MyModel.find === 'function', 'should have data access methods after attaching to a data source'); + MyModel.find(function(err, results) { + assert(results.length === 0, 'should have data access methods after attaching to a data source'); + }); }); }); }); diff --git a/test/relations.integration.js b/test/relations.integration.js index 0c3df8df9..9d9ae16a8 100644 --- a/test/relations.integration.js +++ b/test/relations.integration.js @@ -158,7 +158,6 @@ describe('relations - integration', function () { it.skip('allows to find related objects via where filter', function(done) { //TODO https://github.com/strongloop/loopback-datasource-juggler/issues/94 var expectedProduct = this.product; - // Note: the URL format is not final this.get('/api/products?filter[where][categoryId]=' + this.category.id) .expect(200, function(err, res) { if (err) return done(err); diff --git a/test/replication.test.js b/test/replication.test.js index bda692b34..c22b1396b 100644 --- a/test/replication.test.js +++ b/test/replication.test.js @@ -340,4 +340,4 @@ describe('Replication / Change APIs', function() { assert(!this.conflict); }); }); -}); \ No newline at end of file +}); diff --git a/test/util/model-tests.js b/test/util/model-tests.js index 7927f2b3c..c8948aa64 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -198,10 +198,10 @@ describe('Model Tests', function() { }); }); - describe('Model.deleteById([callback])', function () { + describe('Model.removeById(id, [callback])', function () { it("Delete a model instance from the attached data source", function (done) { User.create({first: 'joe', last: 'bob'}, function (err, user) { - User.deleteById(user.id, function (err) { + User.removeById(user.id, function (err) { User.findById(user.id, function (err, notFound) { assert.equal(notFound, null); done(); From 2de33d4da5b65b504481edc8fbdfef6d3bb8bc2f Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Mon, 19 May 2014 19:54:55 -0700 Subject: [PATCH 31/74] Rework change conflict detection --- lib/models/change.js | 83 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/models/change.js b/lib/models/change.js index 9a8286fd9..bdb3c7078 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -7,7 +7,8 @@ var DataModel = require('./data-model') , crypto = require('crypto') , CJSON = {stringify: require('canonical-json')} , async = require('async') - , assert = require('assert'); + , assert = require('assert') + , debug = require('debug')('loopback:change'); /** * Properties @@ -141,6 +142,7 @@ Change.findOrCreateChange = function(modelName, modelId, callback) { modelName: modelName, modelId: modelId }); + ch.debug('creating change'); ch.save(callback); } }); @@ -160,8 +162,13 @@ Change.prototype.rectify = function(cb) { updateRevision, updateCheckpoint ]; + var currentRev = this.rev; - if(this.rev) this.prev = this.rev; + change.debug('rectify change'); + + cb = cb || function(err) { + if(err) throw new Error(err); + } async.parallel(tasks, function(err) { if(err) return cb(err); @@ -172,7 +179,27 @@ Change.prototype.rectify = function(cb) { // get the current revision change.currentRevision(function(err, rev) { if(err) return Change.handleError(err, cb); - change.rev = rev; + change.debug('updating revision ('+ rev +')'); + // deleted + if(rev) { + // avoid setting rev and prev to the same value + if(currentRev !== rev) { + change.rev = rev; + change.prev = currentRev; + } else { + change.debug('rev and prev are equal (not updating rev)'); + } + } else { + change.rev = null; + if(currentRev) { + change.prev = currentRev; + } else if(!change.prev) { + change.debug('ERROR - could not determing prev'); + change.prev = Change.UNKNOWN; + return cb(new Error('could not determine the previous rev for ' + + change.modelId)); + } + } cb(); }); } @@ -268,6 +295,32 @@ Change.prototype.equals = function(change) { return thisRev === thatRev; } +/** + * Does this change conflict with the given change. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.conflictsWith = function(change) { + if(!change) return false; + if(this.equals(change)) return false; + if(Change.bothDeleted(this, change)) return false; + if(this.isBasedOn(change)) return false; + return true; +} + +/** + * Are both changes deletes? + * @param {Change} a + * @param {Change} b + * @return {Boolean} + */ + +Change.bothDeleted = function(a, b) { + return a.type() === Change.DELETE + && b.type() === Change.DELETE; +} + /** * Determine if the change is based on the given change. * @param {Change} change @@ -335,10 +388,13 @@ Change.diff = function(modelName, since, remoteChanges, callback) { localModelIds.push(localChange.modelId); var remoteChange = remoteChangeIndex[localChange.modelId]; if(remoteChange && !localChange.equals(remoteChange)) { - if(remoteChange.isBasedOn(localChange)) { - deltas.push(remoteChange); - } else { + if(remoteChange.conflictsWith(localChange)) { + remoteChange.debug('remote conflict'); + localChange.debug('local conflict'); conflicts.push(localChange); + } else { + remoteChange.debug('remote delta'); + deltas.push(remoteChange); } } }); @@ -362,6 +418,7 @@ Change.diff = function(modelName, since, remoteChanges, callback) { */ Change.rectifyAll = function(cb) { + debug('rectify all'); var Change = this; // this should be optimized this.find(function(err, changes) { @@ -394,6 +451,19 @@ Change.handleError = function(err) { } } +Change.prototype.debug = function() { + if(debug.enabled) { + var args = Array.prototype.slice.call(arguments); + debug.apply(this, args); + debug('\tid', this.id); + debug('\trev', this.rev); + debug('\tprev', this.prev); + debug('\tmodelName', this.modelName); + debug('\tmodelId', this.modelId); + debug('\ttype', this.type()); + } +} + /** * Get the `Model` class for `change.modelName`. * @return {Model} @@ -510,7 +580,6 @@ Conflict.prototype.changes = function(cb) { } function getTargetChange(cb) { - debugger; conflict.TargetChange.findOne({ modelId: conflict.targetModelId }, function(err, change) { From 52eb72d94f359f57f6811c80d097f8fba3a66148 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 20 May 2014 12:14:51 -0700 Subject: [PATCH 32/74] Remove un-rectify-able changes --- lib/models/change.js | 30 +++++++++++++++++------------- lib/models/data-model.js | 3 ++- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/models/change.js b/lib/models/change.js index bdb3c7078..60a6042ee 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -172,15 +172,19 @@ Change.prototype.rectify = function(cb) { async.parallel(tasks, function(err) { if(err) return cb(err); - change.save(cb); + if(change.prev === Change.UNKNOWN) { + // this occurs when a record of a change doesn't exist + // and its current revision is null (not found) + change.remove(cb); + } else { + change.save(cb); + } }); function updateRevision(cb) { // get the current revision change.currentRevision(function(err, rev) { if(err) return Change.handleError(err, cb); - change.debug('updating revision ('+ rev +')'); - // deleted if(rev) { // avoid setting rev and prev to the same value if(currentRev !== rev) { @@ -196,10 +200,9 @@ Change.prototype.rectify = function(cb) { } else if(!change.prev) { change.debug('ERROR - could not determing prev'); change.prev = Change.UNKNOWN; - return cb(new Error('could not determine the previous rev for ' - + change.modelId)); } } + change.debug('updated revision (was ' + currentRev + ')'); cb(); }); } @@ -493,8 +496,9 @@ Change.prototype.getModel = function(callback) { * * **Note: call `conflict.fetch()` to get the `target` and `source` models. * - * @param {*} sourceModelId - * @param {*} targetModelId + * @param {*} modelId + * @param {DataModel} SourceModel + * @param {DataModel} TargetModel * @property {ModelClass} source The source model instance * @property {ModelClass} target The target model instance */ @@ -570,9 +574,9 @@ Conflict.prototype.changes = function(cb) { ], done); function getSourceChange(cb) { - conflict.SourceChange.findOne({ - modelId: conflict.sourceModelId - }, function(err, change) { + conflict.SourceChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { if(err) return cb(err); sourceChange = change; cb(); @@ -580,9 +584,9 @@ Conflict.prototype.changes = function(cb) { } function getTargetChange(cb) { - conflict.TargetChange.findOne({ - modelId: conflict.targetModelId - }, function(err, change) { + conflict.TargetChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { if(err) return cb(err); targetChange = change; cb(); diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 5661a7904..6b5bf5fb8 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -767,7 +767,8 @@ DataModel.createUpdates = function(deltas, cb) { if(err) return cb(err); if(!inst) { console.error('missing data for change:', change); - return callback(); + return cb && cb(new Error('missing data for change: ' + + change.modelId)); } if(inst.toObject) { update.data = inst.toObject(); From 77bd77e6259b3437437a561da4f902eced3d055a Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 20 May 2014 12:46:43 -0700 Subject: [PATCH 33/74] Ensure changes are created in sync --- lib/models/data-model.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 6b5bf5fb8..973ed7b40 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -882,13 +882,13 @@ DataModel.enableChangeTracking = function() { Change.attachTo(this.dataSource); Change.getCheckpointModel().attachTo(this.dataSource); - Model.on('changed', function(obj) { - Model.rectifyChange(obj.getId(), Model.handleChangeError); - }); + Model.afterSave = function afterSave(next) { + Model.rectifyChange(this.getId(), next); + } - Model.on('deleted', function(id) { - Model.rectifyChange(id, Model.handleChangeError); - }); + Model.afterDestroy = function afterDestroy(next) { + Model.rectifyChange(this.getId(), next); + } Model.on('deletedAll', cleanup); From 1a8ba602ccd8c264416c533ac017995af0e68c15 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 20 May 2014 13:45:47 -0700 Subject: [PATCH 34/74] !fixup Mark DAO methods as delegate Allow juggler to mix in these methods. --- lib/models/data-model.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/models/data-model.js b/lib/models/data-model.js index 973ed7b40..91bbc4a8a 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -403,6 +403,7 @@ DataModel.setupRemoting = function() { function setRemoting(scope, name, options) { var fn = scope[name]; + fn._delegate = true; options.isStatic = scope === DataModel; DataModel.remoteMethod(name, options); } From 0c925f7ceabc2f039940ba7495d142fa69b91e7a Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 20 May 2014 14:08:30 -0700 Subject: [PATCH 35/74] Depend on juggler@1.6.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c5bba969c..e98621316 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "canonical-json": "0.0.3" }, "peerDependencies": { - "loopback-datasource-juggler": "^1.4.0" + "loopback-datasource-juggler": "~1.6.0" }, "devDependencies": { - "loopback-datasource-juggler": "^1.4.0", + "loopback-datasource-juggler": "~1.6.0", "mocha": "~1.17.1", "strong-task-emitter": "0.0.x", "supertest": "~0.9.0", From 7a7f868bf880bdd22096ca1aa7bd6e99b1093869 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 20 May 2014 15:21:52 -0700 Subject: [PATCH 36/74] Add RC version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9eb54cf9a..37c615f16 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "Platform", "mBaaS" ], - "version": "1.8.2", + "version": "1.9.0-rc-1", "scripts": { "test": "mocha -R spec" }, From 7005c4bdd0031b3e04991955efbad59b681b03c7 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 27 May 2014 15:15:50 -0700 Subject: [PATCH 37/74] 2.0.0-beta1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 37c615f16..c92d217f2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "Platform", "mBaaS" ], - "version": "1.9.0-rc-1", + "version": "2.0.0-beta1", "scripts": { "test": "mocha -R spec" }, From 10bbfdcf253e42c76edfea3556af5a759ee278e5 Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Tue, 27 May 2014 16:09:45 -0700 Subject: [PATCH 38/74] 2.0.0-beta2 --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index c92d217f2..f56510f7a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "Platform", "mBaaS" ], - "version": "2.0.0-beta1", + "version": "2.0.0-beta2", "scripts": { "test": "mocha -R spec" }, @@ -31,10 +31,10 @@ "canonical-json": "0.0.3" }, "peerDependencies": { - "loopback-datasource-juggler": "~1.6.0" + "loopback-datasource-juggler": "2.0.0-beta1" }, "devDependencies": { - "loopback-datasource-juggler": "~1.6.0", + "loopback-datasource-juggler": "2.0.0-beta1", "mocha": "~1.17.1", "strong-task-emitter": "0.0.x", "supertest": "~0.9.0", From 0bb69f888c0a4fe3c51dbd114a2fb90b743ebaf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 28 May 2014 18:45:05 +0200 Subject: [PATCH 39/74] package.json: fix malformed json The formatting error was introduced by the previous merge commit. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4b1b9e96d..793b25d80 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "karma": "~0.12.16", "karma-browserify": "~0.2.0", "karma-mocha": "~0.1.3", - "grunt-karma": "~0.8.3" + "grunt-karma": "~0.8.3", "loopback-explorer": "~1.1.0" }, "repository": { From ab0700a69028f5764a8e84c179ef5e0bee9676cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 28 May 2014 18:45:29 +0200 Subject: [PATCH 40/74] 2.0.0-beta3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 793b25d80..0fd3d3d2a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "mobile", "mBaaS" ], - "version": "2.0.0-beta2", + "version": "2.0.0-beta3", "scripts": { "test": "mocha -R spec" }, From 566757ba1dcc5bb705418602b64a824c33844f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 29 May 2014 08:38:39 +0200 Subject: [PATCH 41/74] Deprecate app.boot, remove app.installMiddleware The bootloader has its own project `loopback-boot` now. --- lib/application.js | 130 ++------------------------------------------- 1 file changed, 3 insertions(+), 127 deletions(-) diff --git a/lib/application.js b/lib/application.js index 3eb7a9745..4059de2bf 100644 --- a/lib/application.js +++ b/lib/application.js @@ -333,6 +333,9 @@ app.enableAuth = function() { /** * Initialize an application from an options object or a set of JSON and JavaScript files. * + * **Deprecated. Use the package + * [loopback-boot](https://github.com/strongloop/loopback-boot) instead.** + * * This function takes an optional argument that is either a string or an object. * * If the argument is a string, then it sets the application root directory based on the string value. Then it: @@ -691,133 +694,6 @@ function clearHandlerCache(app) { app._handlers = undefined; } -/*! - * This function is now deprecated. - * Install all express middleware required by LoopBack. - * - * It is possible to inject your own middleware by listening on one of the - * following events: - * - * - `middleware:preprocessors` is emitted after all other - * request-preprocessing middleware was installed, but before any - * request-handling middleware is configured. - * - * Usage: - * ```js - * app.once('middleware:preprocessors', function() { - * app.use(loopback.limit('5.5mb')) - * }); - * ``` - * - `middleware:handlers` is emitted when it's time to add your custom - * request-handling middleware. Note that you should not install any - * express routes at this point (express routes are discussed later). - * - * Usage: - * ```js - * app.once('middleware:handlers', function() { - * app.use('/admin', adminExpressApp); - * app.use('/custom', function(req, res, next) { - * res.send(200, { url: req.url }); - * }); - * }); - * ``` - * - `middleware:error-loggers` is emitted at the end, before the loopback - * error handling middleware is installed. This is the point where you - * can install your own middleware to log errors. - * - * Notes: - * - The middleware function must take four parameters, otherwise it won't - * be called by express. - * - * - It should also call `next(err)` to let the loopback error handler convert - * the error to an HTTP error response. - * - * Usage: - * ```js - * var bunyan = require('bunyan'); - * var log = bunyan.createLogger({name: "myapp"}); - * app.once('middleware:error-loggers', function() { - * app.use(function(err, req, res, next) { - * log.error(err); - * next(err); - * }); - * }); - * ``` - * - * Express routes should be added after `installMiddleware` was called. - * This way the express router middleware is injected at the right place in the - * middleware chain. If you add an express route before calling this function, - * bad things will happen: Express will automatically add the router - * middleware and since we haven't added request-preprocessing middleware like - * cookie & body parser yet, your route handlers will receive raw unprocessed - * requests. - * - * This is the correct order in which to call `app` methods: - * ```js - * app.boot(__dirname); // optional - * - * app.installMiddleware(); - * - * // [register your express routes here] - * - * app.listen(); - * ``` - */ -app.installMiddleware = function() { - var loopback = require('../'); - - /* - * Request pre-processing - */ - this.use(loopback.favicon()); - // TODO(bajtos) refactor to app.get('loggerFormat') - var loggerFormat = this.get('env') === 'development' ? 'dev' : 'default'; - this.use(loopback.logger(loggerFormat)); - this.use(loopback.cookieParser(this.get('cookieSecret'))); - this.use(loopback.token({ model: this.models.accessToken })); - this.use(loopback.bodyParser()); - this.use(loopback.methodOverride()); - - // Allow the app to install custom preprocessing middleware - this.emit('middleware:preprocessors'); - - /* - * Request handling - */ - - // LoopBack REST transport - this.use(this.get('restApiRoot') || '/api', loopback.rest()); - - // Allow the app to install custom request handling middleware - this.emit('middleware:handlers'); - - // Let express routes handle requests that were not handled - // by any of the middleware registered above. - // This way LoopBack REST and API Explorer take precedence over - // express routes. - this.use(this.router); - - // The static file server should come after all other routes - // Every request that goes through the static middleware hits - // the file system to check if a file exists. - this.use(loopback.static(path.join(__dirname, 'public'))); - - // Requests that get this far won't be handled - // by any middleware. Convert them into a 404 error - // that will be handled later down the chain. - this.use(loopback.urlNotFound()); - - /* - * Error handling - */ - - // Allow the app to install custom error logging middleware - this.emit('middleware:error-handlers'); - - // The ultimate error handler. - this.use(loopback.errorHandler()); -}; - /** * Listen for connections and update the configured port. * From 49380e490f878e10c7e3a9e200f6dd0b8472c37c Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Sat, 3 May 2014 11:20:45 -0700 Subject: [PATCH 42/74] Upgrade to Express 4.x --- favicon.ico | Bin 0 -> 894 bytes lib/express-wrapper.js | 60 ++++++++++++++++++++++++++++ lib/loopback.js | 12 ++++-- package.json | 25 +++++++----- test/app.test.js | 2 + test/fixtures/access-control/app.js | 5 ++- 6 files changed, 90 insertions(+), 14 deletions(-) create mode 100644 favicon.ico create mode 100644 lib/express-wrapper.js diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..25695874f31e1113ba20e9a4fc2c7210465f498a GIT binary patch literal 894 zcmZQzU<5(|0R|u`!H~hsz#zuJz@P!dKp_SNAO?wpfCErYm>Yt@0zl$F26*xMad@SL zS|G!U9n&!+(FMwvmWaDBNV|iOq$`6(mcor6Z_pLN___z`l@8P>#PcY4F(`U5sMHy# z9;j2?TM>PJ&X*rwV53U`nW8&`suzR0HwfviNYOvqrgNlO<8Y&NGQW@` z!=;-ifa(MK{DkZnJictAo|c`!Ps*GTeA@aq`{W;#;z%x8=+2DEjp8SJ>H^ASWGaG&tI-)9Ik@ z%b@1ZpybaW>%$=C&T#GC`AK(niEYl7+?FT1qX_7*hj%Y7zr9~`s*hH^vChmuwGaj+ zUj{iJkl{iu43}=7djIQd(Z!`|yUJ!hI-FMTD&fQ+>&l?$!JvDrP3L&KYKNVyCxe7L zNb$rKb#R|T7=cB4B907Ft_-ps3~KYkKz=V$2L^lTgy=6{KcFcFazA~1Gi7DHs0)Ln z8-tWPgM=%?jMdHGzkR|~i7fK`^`o!~bJakG9YxZ=<^gdr GiX;F#Z>XgJ literal 0 HcmV?d00001 diff --git a/lib/express-wrapper.js b/lib/express-wrapper.js new file mode 100644 index 000000000..f12c87a82 --- /dev/null +++ b/lib/express-wrapper.js @@ -0,0 +1,60 @@ +var express = require('express'); +var expressVer = require('express/package.json').version; +var path = require('path'); + +express.version = expressVer; +var middlewares = express.middlewares = {}; + +function safeRequire(m) { + try { + return require(m); + } catch (err) { + return undefined; + } +} + +function createMiddlewareNotInstalled(memberName, moduleName) { + return function() { + throw new Error('The middleware loopback.' + memberName + ' is not installed.\n' + + 'Please run `npm install ' + moduleName + '` to fix the problem.'); + }; +} + +if (expressVer.indexOf('4.') === 0) { + var middlewareModules = { + "compress": "compression", + "timeout": "connect-timeout", + "cookieParser": "cookie-parser", + "cookieSession": "cookie-session", + "csrf": "csurf", + "errorHandler": "errorhandler", + "session": "express-session", + "methodOverride": "method-override", + "logger": "morgan", + "responseTime": "response-time", + "favicon": "serve-favicon", + "directory": "serve-index", + // "static": "serve-static", + "vhost": "vhost" + }; + + middlewares.bodyParser = safeRequire('body-parser'); + middlewares.json = middlewares.bodyParser && middlewares.bodyParser.json; + middlewares.urlencoded = middlewares.bodyParser && middlewares.bodyParser.urlencoded; + + for (var m in middlewareModules) { + var moduleName = middlewareModules[m]; + middlewares[m] = safeRequire(moduleName) || createMiddlewareNotInstalled(m, moduleName); + } + + // serve-favicon requires a path + var favicon = middlewares.favicon; + middlewares.favicon = function(icon, options) { + icon = icon || path.join(__dirname, '../favicon.ico'); + return favicon(icon, options); + }; +} + +module.exports = express; + + diff --git a/lib/loopback.js b/lib/loopback.js index a7a4c5da4..2d1483b4d 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -2,15 +2,12 @@ * Module dependencies. */ -var express = require('express') +var express = require('./express-wrapper') , fs = require('fs') , ejs = require('ejs') - , EventEmitter = require('events').EventEmitter , path = require('path') , proto = require('./application') , DataSource = require('loopback-datasource-juggler').DataSource - , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder - , i8n = require('inflection') , merge = require('util')._extend , assert = require('assert'); @@ -103,6 +100,13 @@ for (var key in express) { , Object.getOwnPropertyDescriptor(express, key)); } +for (var key in express.middlewares) { + Object.defineProperty( + loopback + , key + , Object.getOwnPropertyDescriptor(express.middlewares, key)); +} + /*! * Expose additional loopback middleware * for example `loopback.configure` etc. diff --git a/package.json b/package.json index 0fd3d3d2a..9a5518b43 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,15 @@ }, "dependencies": { "debug": "~0.8.1", - "express": "~3.5.0", - "strong-remoting": "~1.4.0", + "express": "4.x", + "body-parser": "~1.2.2", + "express-session": "~1.2.1", + "serve-favicon": "~2.0.0", + "method-override": "~1.0.2", + "cookie-parser": "~1.1.0", + "morgan": "~1.1.1", + "errorhandler": "~1.0.1", + "strong-remoting": "~1.4.1", "inflection": "~1.3.5", "passport": "~0.2.0", "passport-local": "~1.0.0", @@ -44,7 +51,7 @@ "underscore": "~1.6.0", "uid2": "0.0.3", "async": "~0.9.0", - "canonical-json": "0.0.3" + "canonical-json": "0.0.4" }, "peerDependencies": { "loopback-datasource-juggler": "2.0.0-beta1" @@ -53,25 +60,25 @@ "loopback-datasource-juggler": "2.0.0-beta1", "mocha": "~1.18.0", "strong-task-emitter": "0.0.x", - "supertest": "~0.12.1", + "supertest": "~0.13.0", "chai": "~1.9.1", - "loopback-testing": "~0.1.2", - "browserify": "~4.1.5", + "loopback-testing": "~0.1.3", + "browserify": "~4.1.6", "grunt": "~0.4.5", "grunt-browserify": "~2.1.0", "grunt-contrib-uglify": "~0.4.0", "grunt-contrib-jshint": "~0.10.0", "grunt-contrib-watch": "~0.6.1", "karma-script-launcher": "~0.1.0", - "karma-chrome-launcher": "~0.1.3", + "karma-chrome-launcher": "~0.1.4", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", "karma-phantomjs-launcher": "~0.1.4", "karma": "~0.12.16", - "karma-browserify": "~0.2.0", + "karma-browserify": "~0.2.1", "karma-mocha": "~0.1.3", "grunt-karma": "~0.8.3", - "loopback-explorer": "~1.1.0" + "loopback-explorer": "~1.1.1" }, "repository": { "type": "git", diff --git a/test/app.test.js b/test/app.test.js index 35f9e46d0..915e7d241 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -327,6 +327,7 @@ describe('app', function() { }); }); + /* describe('installMiddleware()', function() { var app; beforeEach(function() { app = loopback(); }); @@ -422,6 +423,7 @@ describe('app', function() { .end(done); }); }); + */ describe('listen()', function() { it('starts http server', function(done) { diff --git a/test/fixtures/access-control/app.js b/test/fixtures/access-control/app.js index e4c210564..47ce2dc3d 100644 --- a/test/fixtures/access-control/app.js +++ b/test/fixtures/access-control/app.js @@ -8,7 +8,10 @@ var apiPath = '/api'; app.use(loopback.cookieParser('secret')); app.use(loopback.token({model: app.models.accessToken})); app.use(apiPath, loopback.rest()); -app.use(app.router); + +if(loopback.isExpress3) { + app.use(app.router); +} app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); app.enableAuth(); From 3c7cfcaca8e5886ee9550290edb65b9d02cc6a91 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 29 May 2014 10:18:14 -0700 Subject: [PATCH 43/74] Clean up the tests --- test/app.test.js | 98 ----------------------------- test/fixtures/access-control/app.js | 3 - 2 files changed, 101 deletions(-) diff --git a/test/app.test.js b/test/app.test.js index 915e7d241..01ad80224 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -327,104 +327,6 @@ describe('app', function() { }); }); - /* - describe('installMiddleware()', function() { - var app; - beforeEach(function() { app = loopback(); }); - - it('installs loopback.token', function(done) { - app.models.accessToken = loopback.AccessToken; - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ accessTokenId: req.accessToken && req.accessToken.id }); - }); - - app.models.accessToken.create({}, function(err, token) { - if (err) done(err); - request(app).get('/') - .set('Authorization', token.id) - .expect(200, { accessTokenId: token.id }) - .end(done); - }); - }); - - it('emits "middleware:preprocessors" before handlers are installed', - function(done) { - app.on('middleware:preprocessors', function() { - this.use(function(req, res, next) { - req.preprocessed = true; - next(); - }); - }); - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ preprocessed: req.preprocessed }); - }); - - request(app).get('/') - .expect(200, { preprocessed: true}) - .end(done); - } - ); - - it('emits "middleware:handlers before installing express router', - function(done) { - app.on('middleware:handlers', function() { - this.use(function(req, res, next) { - res.send({ handler: 'middleware' }); - }); - }); - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ handler: 'router' }); - }); - - request(app).get('/') - .expect(200, { handler: 'middleware' }) - .end(done); - } - ); - - it('emits "middleware:error-handlers" after all request handlers', - function(done) { - var logs = []; - app.on('middleware:error-handlers', function() { - app.use(function(err, req, res, next) { - logs.push(req.url); - next(err); - }); - }); - - app.installMiddleware(); - - request(app).get('/not-found') - .expect(404) - .end(function(err, res) { - if (err) done(err); - expect(logs).to.eql(['/not-found']); - done(); - }); - } - ); - - it('installs REST transport', function(done) { - app.model(loopback.Application); - app.set('restApiRoot', '/api'); - app.installMiddleware(); - - request(app).get('/api/applications') - .expect(200, []) - .end(done); - }); - }); - */ - describe('listen()', function() { it('starts http server', function(done) { var app = loopback(); diff --git a/test/fixtures/access-control/app.js b/test/fixtures/access-control/app.js index 47ce2dc3d..fb01c3dac 100644 --- a/test/fixtures/access-control/app.js +++ b/test/fixtures/access-control/app.js @@ -9,9 +9,6 @@ app.use(loopback.cookieParser('secret')); app.use(loopback.token({model: app.models.accessToken})); app.use(apiPath, loopback.rest()); -if(loopback.isExpress3) { - app.use(app.router); -} app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); app.enableAuth(); From bb0ddb26ec0206ff6beb0e5ef5ed3b066989eee3 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 29 May 2014 10:18:58 -0700 Subject: [PATCH 44/74] Rename express-wrapper to express-middleware --- lib/express-middleware.js | 56 ++++++++++++++++++++++++++++++++++++ lib/express-wrapper.js | 60 --------------------------------------- lib/loopback.js | 2 +- 3 files changed, 57 insertions(+), 61 deletions(-) create mode 100644 lib/express-middleware.js delete mode 100644 lib/express-wrapper.js diff --git a/lib/express-middleware.js b/lib/express-middleware.js new file mode 100644 index 000000000..4da2e598d --- /dev/null +++ b/lib/express-middleware.js @@ -0,0 +1,56 @@ +var express = require('express'); +var path = require('path'); + +var middlewares = express.middlewares = {}; + +function safeRequire(m) { + try { + return require(m); + } catch (err) { + return undefined; + } +} + +function createMiddlewareNotInstalled(memberName, moduleName) { + return function () { + throw new Error('The middleware loopback.' + memberName + ' is not installed.\n' + + 'Please run `npm install ' + moduleName + '` to fix the problem.'); + }; +} + +var middlewareModules = { + "compress": "compression", + "timeout": "connect-timeout", + "cookieParser": "cookie-parser", + "cookieSession": "cookie-session", + "csrf": "csurf", + "errorHandler": "errorhandler", + "session": "express-session", + "methodOverride": "method-override", + "logger": "morgan", + "responseTime": "response-time", + "favicon": "serve-favicon", + "directory": "serve-index", + // "static": "serve-static", + "vhost": "vhost" +}; + +middlewares.bodyParser = safeRequire('body-parser'); +middlewares.json = middlewares.bodyParser && middlewares.bodyParser.json; +middlewares.urlencoded = middlewares.bodyParser && middlewares.bodyParser.urlencoded; + +for (var m in middlewareModules) { + var moduleName = middlewareModules[m]; + middlewares[m] = safeRequire(moduleName) || createMiddlewareNotInstalled(m, moduleName); +} + +// serve-favicon requires a path +var favicon = middlewares.favicon; +middlewares.favicon = function (icon, options) { + icon = icon || path.join(__dirname, '../favicon.ico'); + return favicon(icon, options); +}; + +module.exports = express; + + diff --git a/lib/express-wrapper.js b/lib/express-wrapper.js deleted file mode 100644 index f12c87a82..000000000 --- a/lib/express-wrapper.js +++ /dev/null @@ -1,60 +0,0 @@ -var express = require('express'); -var expressVer = require('express/package.json').version; -var path = require('path'); - -express.version = expressVer; -var middlewares = express.middlewares = {}; - -function safeRequire(m) { - try { - return require(m); - } catch (err) { - return undefined; - } -} - -function createMiddlewareNotInstalled(memberName, moduleName) { - return function() { - throw new Error('The middleware loopback.' + memberName + ' is not installed.\n' + - 'Please run `npm install ' + moduleName + '` to fix the problem.'); - }; -} - -if (expressVer.indexOf('4.') === 0) { - var middlewareModules = { - "compress": "compression", - "timeout": "connect-timeout", - "cookieParser": "cookie-parser", - "cookieSession": "cookie-session", - "csrf": "csurf", - "errorHandler": "errorhandler", - "session": "express-session", - "methodOverride": "method-override", - "logger": "morgan", - "responseTime": "response-time", - "favicon": "serve-favicon", - "directory": "serve-index", - // "static": "serve-static", - "vhost": "vhost" - }; - - middlewares.bodyParser = safeRequire('body-parser'); - middlewares.json = middlewares.bodyParser && middlewares.bodyParser.json; - middlewares.urlencoded = middlewares.bodyParser && middlewares.bodyParser.urlencoded; - - for (var m in middlewareModules) { - var moduleName = middlewareModules[m]; - middlewares[m] = safeRequire(moduleName) || createMiddlewareNotInstalled(m, moduleName); - } - - // serve-favicon requires a path - var favicon = middlewares.favicon; - middlewares.favicon = function(icon, options) { - icon = icon || path.join(__dirname, '../favicon.ico'); - return favicon(icon, options); - }; -} - -module.exports = express; - - diff --git a/lib/loopback.js b/lib/loopback.js index 2d1483b4d..47a575e9b 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -2,7 +2,7 @@ * Module dependencies. */ -var express = require('./express-wrapper') +var express = require('./express-middleware') , fs = require('fs') , ejs = require('ejs') , path = require('path') From d6ae28cf8754bee00048cd112256445ff230f024 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Thu, 29 May 2014 21:35:57 -0700 Subject: [PATCH 45/74] Update strong-remoting dep --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9a5518b43..b6b78d54c 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "cookie-parser": "~1.1.0", "morgan": "~1.1.1", "errorhandler": "~1.0.1", - "strong-remoting": "~1.4.1", + "strong-remoting": "2.0.0-beta2", "inflection": "~1.3.5", "passport": "~0.2.0", "passport-local": "~1.0.0", From c10305754f2e4d92465a92484f7781c8337b59e8 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Fri, 30 May 2014 17:08:03 -0700 Subject: [PATCH 46/74] Clean up express middleware dependencies --- package.json | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b6b78d54c..24be7f553 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,6 @@ "debug": "~0.8.1", "express": "4.x", "body-parser": "~1.2.2", - "express-session": "~1.2.1", - "serve-favicon": "~2.0.0", - "method-override": "~1.0.2", - "cookie-parser": "~1.1.0", - "morgan": "~1.1.1", - "errorhandler": "~1.0.1", "strong-remoting": "2.0.0-beta2", "inflection": "~1.3.5", "passport": "~0.2.0", @@ -57,6 +51,9 @@ "loopback-datasource-juggler": "2.0.0-beta1" }, "devDependencies": { + "cookie-parser": "~1.1.0", + "errorhandler": "~1.0.1", + "serve-favicon": "~2.0.0", "loopback-datasource-juggler": "2.0.0-beta1", "mocha": "~1.18.0", "strong-task-emitter": "0.0.x", From 5b53da93db53dae30f1cd368ac98e7cd59a8cfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 2 Jun 2014 08:33:41 +0200 Subject: [PATCH 47/74] test: Remove forgotten call of `console.log()` The `console.log()` call was added by 94ec5c2. --- test/app.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/test/app.test.js b/test/app.test.js index 35f9e46d0..b7fbd6ebe 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -549,7 +549,6 @@ describe('app', function() { it('adds a camelized alias', function() { app.connector('FOO-BAR', loopback.Memory); - console.log(app.connectors); expect(app.connectors.FOOBAR).to.equal(loopback.Memory); }); }); From 88a4bb462e6b8765ec04ccb37d69a810afed7fe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 3 Jun 2014 10:15:15 +0200 Subject: [PATCH 48/74] Exclude express-middleware from browser bundle Fix lib/loopback to include express-middleware only on the server. Bump up strong-remoting dependency to use the version working in browsers. --- lib/express-middleware.js | 6 +----- lib/loopback.js | 26 ++++++++++++++++++-------- package.json | 2 +- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/lib/express-middleware.js b/lib/express-middleware.js index 4da2e598d..cc67278c0 100644 --- a/lib/express-middleware.js +++ b/lib/express-middleware.js @@ -1,7 +1,7 @@ var express = require('express'); var path = require('path'); -var middlewares = express.middlewares = {}; +var middlewares = exports; function safeRequire(m) { try { @@ -50,7 +50,3 @@ middlewares.favicon = function (icon, options) { icon = icon || path.join(__dirname, '../favicon.ico'); return favicon(icon, options); }; - -module.exports = express; - - diff --git a/lib/loopback.js b/lib/loopback.js index 47a575e9b..4f9859ca3 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -2,7 +2,7 @@ * Module dependencies. */ -var express = require('./express-middleware') +var express = require('express') , fs = require('fs') , ejs = require('ejs') , path = require('path') @@ -89,8 +89,7 @@ function createApplication() { } /*! - * Expose express.middleware as loopback.* - * for example `loopback.errorHandler` etc. + * Expose static express methods like `express.errorHandler`. */ for (var key in express) { @@ -100,11 +99,22 @@ for (var key in express) { , Object.getOwnPropertyDescriptor(express, key)); } -for (var key in express.middlewares) { - Object.defineProperty( - loopback - , key - , Object.getOwnPropertyDescriptor(express.middlewares, key)); +/*! + * Expose additional middleware like session as loopback.* + * This will keep the loopback API compatible with express 3.x + * + * ***only in node*** + */ + +if (loopback.isServer) { + var middlewares = require('./express-middleware'); + + for (var key in middlewares) { + Object.defineProperty( + loopback + , key + , Object.getOwnPropertyDescriptor(middlewares, key)); + } } /*! diff --git a/package.json b/package.json index 24be7f553..a1601bf03 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "debug": "~0.8.1", "express": "4.x", "body-parser": "~1.2.2", - "strong-remoting": "2.0.0-beta2", + "strong-remoting": "2.0.0-beta3", "inflection": "~1.3.5", "passport": "~0.2.0", "passport-local": "~1.0.0", From 26874fc715ecf1e71ff06998bc89fb09f2149112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 3 Jun 2014 10:39:54 +0200 Subject: [PATCH 49/74] Make app.get/app.set available in browser Implement settings object and methods in browser-express. Add test/app.test.js to unit-tests run by karma. --- Gruntfile.js | 3 ++- lib/browser-express.js | 20 +++++++++++++++++++- test/app.test.js | 39 +++++++++++++++++++++++++++++++++------ test/model.test.js | 2 ++ test/util/it.js | 19 +++++++++++++++++++ 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 test/util/it.js diff --git a/Gruntfile.js b/Gruntfile.js index 2d3626896..205feff8f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -66,7 +66,8 @@ module.exports = function(grunt) { files: [ 'test/support.js', 'test/model.test.js', - 'test/geo-point.test.js' + 'test/geo-point.test.js', + 'test/app.test.js' ], // list of files to exclude diff --git a/lib/browser-express.js b/lib/browser-express.js index 386e8159c..82aba2fa4 100644 --- a/lib/browser-express.js +++ b/lib/browser-express.js @@ -1,7 +1,25 @@ module.exports = browserExpress; function browserExpress() { - return {}; + return new BrowserExpress(); } browserExpress.errorHandler = {}; + +function BrowserExpress() { + this.settings = {}; +} + +BrowserExpress.prototype.set = function(key, value) { + if (arguments.length == 1) { + return this.get(key); + } + + this.settings[key] = value; + + return this; // fluent API +}; + +BrowserExpress.prototype.get = function(key) { + return this.settings[key]; +}; diff --git a/test/app.test.js b/test/app.test.js index 0d38ff2fc..2ab4e287e 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -3,6 +3,9 @@ var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); var loopback = require('../'); var DataModel = loopback.DataModel; +var describe = require('./util/describe'); +var it = require('./util/it'); + describe('app', function() { describe('app.model(Model)', function() { @@ -35,7 +38,7 @@ describe('app', function() { expect(classes).to.contain('color'); }); - it('updates REST API when a new model is added', function(done) { + it.onServer('updates REST API when a new model is added', function(done) { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { if (err) return done(err); @@ -76,6 +79,8 @@ describe('app', function() { app = loopback(); app.boot({ app: {port: 3000, host: '127.0.0.1'}, + // prevent loading of models.json, it is not available in the browser + models: {}, dataSources: { db: { connector: 'memory' @@ -144,7 +149,7 @@ describe('app', function() { }); }); - describe('app.boot([options])', function () { + describe.onServer('app.boot([options])', function () { beforeEach(function () { app.boot({ app: { @@ -313,7 +318,7 @@ describe('app', function() { }); }); - describe('app.boot(appRootDir)', function () { + describe.onServer('app.boot(appRootDir)', function () { it('Load config files', function () { var app = loopback(); @@ -327,7 +332,7 @@ describe('app', function() { }); }); - describe('listen()', function() { + describe.onServer('listen()', function() { it('starts http server', function(done) { var app = loopback(); app.set('port', 0); @@ -387,7 +392,7 @@ describe('app', function() { }); }); - describe('enableAuth', function() { + describe.onServer('enableAuth', function() { it('should set app.isAuthEnabled to true', function() { expect(app.isAuthEnabled).to.not.equal(true); app.enableAuth(); @@ -395,7 +400,7 @@ describe('app', function() { }); }); - describe('app.get("/", loopback.status())', function () { + describe.onServer('app.get("/", loopback.status())', function () { it('should return the status of the application', function (done) { var app = loopback(); app.get('/', loopback.status()); @@ -456,4 +461,26 @@ describe('app', function() { expect(app.connectors.FOOBAR).to.equal(loopback.Memory); }); }); + + describe('app.settings', function() { + it('can be altered via `app.set(key, value)`', function() { + app.set('write-key', 'write-value'); + expect(app.settings).to.have.property('write-key', 'write-value'); + }); + + it('can be read via `app.get(key)`', function() { + app.settings['read-key'] = 'read-value'; + expect(app.get('read-key')).to.equal('read-value'); + }); + + it('is unique per app instance', function() { + var app1 = loopback(); + var app2 = loopback(); + + expect(app1.settings).to.not.equal(app2.settings); + + app1.set('key', 'value'); + expect(app2.get('key'), 'app2 value').to.equal(undefined); + }); + }); }); diff --git a/test/model.test.js b/test/model.test.js index b00f41de3..10d02a7b8 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -5,6 +5,8 @@ var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); var DataModel = loopback.DataModel; +var describe = require('./util/describe'); + describe('Model / DataModel', function() { defineModelTestsWithDataSource({ dataSource: { diff --git a/test/util/it.js b/test/util/it.js new file mode 100644 index 000000000..f1b004e24 --- /dev/null +++ b/test/util/it.js @@ -0,0 +1,19 @@ +var loopback = require('../../'); + +module.exports = it; + +it.onServer = function itOnServer(name, fn) { + if (loopback.isServer) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; + +it.inBrowser = function itInBrowser(name, fn) { + if (loopback.isBrowser) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; From ea5b9d16fc50d733b7aab705aba51e504f13bdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 5 Jun 2014 09:45:09 +0200 Subject: [PATCH 50/74] Rename DataModel to PersistedModel --- example/client-server/models.js | 2 +- lib/loopback.js | 31 +++- lib/models/acl.js | 2 +- lib/models/application.js | 2 +- lib/models/change.js | 14 +- lib/models/checkpoint.js | 6 +- .../{data-model.js => persisted-model.js} | 162 +++++++++--------- lib/models/user.js | 6 +- test/access-token.test.js | 2 +- test/acl.test.js | 2 +- test/app.test.js | 10 +- test/change.test.js | 2 +- test/data-source.test.js | 4 +- test/fixtures/e2e/models.js | 4 +- test/hidden-properties.test.js | 4 +- test/model.test.js | 10 +- test/remote-connector.test.js | 4 +- test/replication.test.js | 6 +- test/util/model-tests.js | 10 +- 19 files changed, 154 insertions(+), 129 deletions(-) rename lib/models/{data-model.js => persisted-model.js} (84%) diff --git a/example/client-server/models.js b/example/client-server/models.js index c14485c8f..f6dc594b7 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -1,6 +1,6 @@ var loopback = require('../../'); -var CartItem = exports.CartItem = loopback.DataModel.extend('CartItem', { +var CartItem = exports.CartItem = loopback.PersistedModel.extend('CartItem', { tax: {type: Number, default: 0.1}, price: Number, item: String, diff --git a/lib/loopback.js b/lib/loopback.js index 4f9859ca3..6e10d4faa 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -178,10 +178,22 @@ loopback.createModel = function (name, properties, options) { var BaseModel = options.base || options.super; if(typeof BaseModel === 'string') { + var baseName = BaseModel; BaseModel = loopback.getModel(BaseModel); + + if (BaseModel === undefined) { + if (baseName === 'DataModel') { + console.warn('Model `%s` is extending deprecated `DataModel. ' + + 'Use `PeristedModel` instead.', name); + BaseModel = loopback.PersistedModel; + } else { + console.warn('Model `%s` is extending an unknown model `%s`. ' + + 'Using `PersistedModel` as the base.', name, baseName); + } + } } - BaseModel = BaseModel || loopback.DataModel; + BaseModel = BaseModel || loopback.PersistedModel; var model = BaseModel.extend(name, properties, options); @@ -337,7 +349,20 @@ loopback.autoAttachModel = function(ModelCtor) { */ loopback.Model = require('./models/model'); -loopback.DataModel = require('./models/data-model'); +loopback.PersistedModel = require('./models/persisted-model'); + +// temporary alias to simplify migration of code based on <=2.0.0-beta3 +Object.defineProperty(loopback, 'DataModel', { + get: function() { + var stackLines = new Error().stack.split('\n'); + console.warn('loopback.DataModel is deprecated, ' + + 'use loopback.PersistedModel instead.'); + // Log the location where loopback.DataModel was called + console.warn(stackLines[2]); + return loopback.PersistedModel; + } +}); + loopback.Email = require('./models/email'); loopback.User = require('./models/user'); loopback.Application = require('./models/application'); @@ -358,7 +383,7 @@ var dataSourceTypes = { }; loopback.Email.autoAttach = dataSourceTypes.MAIL; -loopback.DataModel.autoAttach = dataSourceTypes.DB; +loopback.PersistedModel.autoAttach = dataSourceTypes.DB; loopback.User.autoAttach = dataSourceTypes.DB; loopback.AccessToken.autoAttach = dataSourceTypes.DB; loopback.Role.autoAttach = dataSourceTypes.DB; diff --git a/lib/models/acl.js b/lib/models/acl.js index 6c2456bb4..7dc1d03c8 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -91,7 +91,7 @@ var ACLSchema = { * @inherits Model */ -var ACL = loopback.DataModel.extend('ACL', ACLSchema); +var ACL = loopback.PersistedModel.extend('ACL', ACLSchema); ACL.ALL = AccessContext.ALL; diff --git a/lib/models/application.js b/lib/models/application.js index 8d70f47e0..1201fca3d 100644 --- a/lib/models/application.js +++ b/lib/models/application.js @@ -113,7 +113,7 @@ function generateKey(hmacKey, algorithm, encoding) { * @inherits {Model} */ -var Application = loopback.DataModel.extend('Application', ApplicationSchema); +var Application = loopback.PersistedModel.extend('Application', ApplicationSchema); /*! * A hook to generate keys before creation diff --git a/lib/models/change.js b/lib/models/change.js index 60a6042ee..654207259 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var DataModel = require('./data-model') +var PersistedModel = require('./persisted-model') , loopback = require('../loopback') , crypto = require('crypto') , CJSON = {stringify: require('canonical-json')} @@ -45,7 +45,7 @@ var options = { * @inherits {Model} */ -var Change = module.exports = DataModel.extend('Change', properties, options); +var Change = module.exports = PersistedModel.extend('Change', properties, options); /*! * Constants @@ -67,7 +67,7 @@ Change.Conflict = Conflict; */ Change.setup = function() { - DataModel.setup.call(this); + PersistedModel.setup.call(this); var Change = this; Change.getter.id = function() { @@ -497,8 +497,8 @@ Change.prototype.getModel = function(callback) { * **Note: call `conflict.fetch()` to get the `target` and `source` models. * * @param {*} modelId - * @param {DataModel} SourceModel - * @param {DataModel} TargetModel + * @param {PersistedModel} SourceModel + * @param {PersistedModel} TargetModel * @property {ModelClass} source The source model instance * @property {ModelClass} target The target model instance */ @@ -516,8 +516,8 @@ function Conflict(modelId, SourceModel, TargetModel) { * * @callback {Function} callback * @param {Error} - * @param {DataModel} source - * @param {DataModel} target + * @param {PersistedModel} source + * @param {PersistedModel} target */ Conflict.prototype.models = function(cb) { diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index c1fa7d70f..704296c2d 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var DataModel = require('../loopback').DataModel +var PersistedModel = require('../loopback').PersistedModel , loopback = require('../loopback') , assert = require('assert'); @@ -32,10 +32,10 @@ var options = { * @property sourceId {String} the source identifier * * @class - * @inherits {DataModel} + * @inherits {PersistedModel} */ -var Checkpoint = module.exports = DataModel.extend('Checkpoint', properties, options); +var Checkpoint = module.exports = PersistedModel.extend('Checkpoint', properties, options); /** * Get the current checkpoint id diff --git a/lib/models/data-model.js b/lib/models/persisted-model.js similarity index 84% rename from lib/models/data-model.js rename to lib/models/persisted-model.js index 048a563dc..540eb29b6 100644 --- a/lib/models/data-model.js +++ b/lib/models/persisted-model.js @@ -16,43 +16,43 @@ var async = require('async'); * Listen for model changes using the `change` event. * * ```js - * MyDataModel.on('changed', function(obj) { + * MyPersistedModel.on('changed', function(obj) { * console.log(obj) // => the changed model * }); * ``` * - * @class DataModel + * @class PersistedModel * @param {Object} data * @param {Number} data.id The default id property */ -var DataModel = module.exports = Model.extend('DataModel'); +var PersistedModel = module.exports = Model.extend('PersistedModel'); /*! - * Setup the `DataModel` constructor. + * Setup the `PersistedModel` constructor. */ -DataModel.setup = function setupDataModel() { +PersistedModel.setup = function setupPersistedModel() { // call Model.setup first Model.setup.call(this); - var DataModel = this; + var PersistedModel = this; var typeName = this.modelName; // setup a remoting type converter for this model RemoteObjects.convert(typeName, function(val) { - return val ? new DataModel(val) : val; + return val ? new PersistedModel(val) : val; }); // enable change tracking (usually for replication) if(this.settings.trackChanges) { - DataModel._defineChangeModel(); - DataModel.once('dataSourceAttached', function() { - DataModel.enableChangeTracking(); + PersistedModel._defineChangeModel(); + PersistedModel.once('dataSourceAttached', function() { + PersistedModel.enableChangeTracking(); }); } - DataModel.setupRemoting(); + PersistedModel.setupRemoting(); } /*! @@ -63,7 +63,7 @@ function throwNotAttached(modelName, methodName) { throw new Error( 'Cannot call ' + modelName + '.'+ methodName + '().' + ' The ' + methodName + ' method has not been setup.' - + ' The DataModel has not been correctly attached to a DataSource!' + + ' The PersistedModel has not been correctly attached to a DataSource!' ); } @@ -95,7 +95,7 @@ function convertNullToNotFoundError(ctx, cb) { * - instance (null or Model) */ -DataModel.create = function (data, callback) { +PersistedModel.create = function (data, callback) { throwNotAttached(this.modelName, 'create'); }; @@ -105,7 +105,7 @@ DataModel.create = function (data, callback) { * @param {Function} [callback] The callback function */ -DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { +PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { throwNotAttached(this.modelName, 'upsert'); }; @@ -118,7 +118,7 @@ DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { * @param {Function} cb - callback called with (err, instance) */ -DataModel.findOrCreate = function findOrCreate(query, data, callback) { +PersistedModel.findOrCreate = function findOrCreate(query, data, callback) { throwNotAttached(this.modelName, 'findOrCreate'); }; @@ -129,7 +129,7 @@ DataModel.findOrCreate = function findOrCreate(query, data, callback) { * @param {Function} cb - callbacl called with (err, exists: Bool) */ -DataModel.exists = function exists(id, cb) { +PersistedModel.exists = function exists(id, cb) { throwNotAttached(this.modelName, 'exists'); }; @@ -140,7 +140,7 @@ DataModel.exists = function exists(id, cb) { * @param {Function} cb - callback called with (err, instance) */ -DataModel.findById = function find(id, cb) { +PersistedModel.findById = function find(id, cb) { throwNotAttached(this.modelName, 'findById'); }; @@ -151,7 +151,7 @@ DataModel.findById = function find(id, cb) { * @param {Object} params (optional) * * - where: Object `{ key: val, key2: {gt: 'val2'}}` - * - include: String, Object or Array. See DataModel.include documentation. + * - include: String, Object or Array. See PersistedModel.include documentation. * - order: String * - limit: Number * - skip: Number @@ -162,7 +162,7 @@ DataModel.findById = function find(id, cb) { * - Array of instances */ -DataModel.find = function find(params, cb) { +PersistedModel.find = function find(params, cb) { throwNotAttached(this.modelName, 'find'); }; @@ -173,7 +173,7 @@ DataModel.find = function find(params, cb) { * @param {Function} cb - callback called with (err, instance) */ -DataModel.findOne = function findOne(params, cb) { +PersistedModel.findOne = function findOne(params, cb) { throwNotAttached(this.modelName, 'findOne'); }; @@ -183,9 +183,9 @@ DataModel.findOne = function findOne(params, cb) { * @param {Function} [cb] - callback called with (err) */ -DataModel.remove = -DataModel.deleteAll = -DataModel.destroyAll = function destroyAll(where, cb) { +PersistedModel.remove = +PersistedModel.deleteAll = +PersistedModel.destroyAll = function destroyAll(where, cb) { throwNotAttached(this.modelName, 'destroyAll'); }; @@ -195,9 +195,9 @@ DataModel.destroyAll = function destroyAll(where, cb) { * @param {Function} cb - callback called with (err) */ -DataModel.removeById = -DataModel.deleteById = -DataModel.destroyById = function deleteById(id, cb) { +PersistedModel.removeById = +PersistedModel.deleteById = +PersistedModel.destroyById = function deleteById(id, cb) { throwNotAttached(this.modelName, 'deleteById'); }; @@ -208,7 +208,7 @@ DataModel.destroyById = function deleteById(id, cb) { * @param {Function} cb - callback, called with (err, count) */ -DataModel.count = function (where, cb) { +PersistedModel.count = function (where, cb) { throwNotAttached(this.modelName, 'count'); }; @@ -219,7 +219,7 @@ DataModel.count = function (where, cb) { * @param callback(err, obj) */ -DataModel.prototype.save = function (options, callback) { +PersistedModel.prototype.save = function (options, callback) { var Model = this.constructor; if (typeof options == 'function') { @@ -286,7 +286,7 @@ DataModel.prototype.save = function (options, callback) { * @returns {Boolean} */ -DataModel.prototype.isNewRecord = function () { +PersistedModel.prototype.isNewRecord = function () { throwNotAttached(this.constructor.modelName, 'isNewRecord'); }; @@ -296,13 +296,13 @@ DataModel.prototype.isNewRecord = function () { * @triggers `destroy` hook (async) before and after destroying object */ -DataModel.prototype.remove = -DataModel.prototype.delete = -DataModel.prototype.destroy = function (cb) { +PersistedModel.prototype.remove = +PersistedModel.prototype.delete = +PersistedModel.prototype.destroy = function (cb) { throwNotAttached(this.constructor.modelName, 'destroy'); }; -DataModel.prototype.destroy._delegate = true; +PersistedModel.prototype.destroy._delegate = true; /** * Update single attribute @@ -314,7 +314,7 @@ DataModel.prototype.destroy._delegate = true; * @param {Function} callback - callback called with (err, instance) */ -DataModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { +PersistedModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { throwNotAttached(this.constructor.modelName, 'updateAttribute'); }; @@ -328,7 +328,7 @@ DataModel.prototype.updateAttribute = function updateAttribute(name, value, call * @param {Function} callback - callback called with (err, instance) */ -DataModel.prototype.updateAttributes = function updateAttributes(data, cb) { +PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) { throwNotAttached(this.modelName, 'updateAttributes'); }; @@ -339,12 +339,12 @@ DataModel.prototype.updateAttributes = function updateAttributes(data, cb) { * @param {Function} callback - called with (err, instance) arguments */ -DataModel.prototype.reload = function reload(callback) { +PersistedModel.prototype.reload = function reload(callback) { throwNotAttached(this.constructor.modelName, 'reload'); }; /** - * Set the correct `id` property for the `DataModel`. If a `Connector` defines + * Set the correct `id` property for the `PersistedModel`. If a `Connector` defines * a `setId` method it will be used. Otherwise the default lookup is used. You * should override this method to handle complex ids. * @@ -352,18 +352,18 @@ DataModel.prototype.reload = function reload(callback) { * specifies. */ -DataModel.prototype.setId = function(val) { +PersistedModel.prototype.setId = function(val) { var ds = this.getDataSource(); this[this.getIdName()] = val; } /** - * Get the `id` value for the `DataModel`. + * Get the `id` value for the `PersistedModel`. * * @returns {*} The `id` value */ -DataModel.prototype.getId = function() { +PersistedModel.prototype.getId = function() { var data = this.toObject(); if(!data) return; return data[this.getIdName()]; @@ -375,7 +375,7 @@ DataModel.prototype.getId = function() { * @returns {String} The `id` property name */ -DataModel.prototype.getIdName = function() { +PersistedModel.prototype.getIdName = function() { return this.constructor.getIdName(); } @@ -385,7 +385,7 @@ DataModel.prototype.getIdName = function() { * @returns {String} The `id` property name */ -DataModel.getIdName = function() { +PersistedModel.getIdName = function() { var Model = this; var ds = Model.getDataSource(); @@ -396,40 +396,40 @@ DataModel.getIdName = function() { } } -DataModel.setupRemoting = function() { - var DataModel = this; - var typeName = DataModel.modelName; - var options = DataModel.settings; +PersistedModel.setupRemoting = function() { + var PersistedModel = this; + var typeName = PersistedModel.modelName; + var options = PersistedModel.settings; function setRemoting(scope, name, options) { var fn = scope[name]; fn._delegate = true; - options.isStatic = scope === DataModel; - DataModel.remoteMethod(name, options); + options.isStatic = scope === PersistedModel; + PersistedModel.remoteMethod(name, options); } - setRemoting(DataModel, 'create', { + setRemoting(PersistedModel, 'create', { description: 'Create a new instance of the model and persist it into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'post', path: '/'} }); - setRemoting(DataModel, 'upsert', { + setRemoting(PersistedModel, 'upsert', { description: 'Update an existing model instance or insert a new one into the data source', accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'put', path: '/'} }); - setRemoting(DataModel, 'exists', { + setRemoting(PersistedModel, 'exists', { description: 'Check whether a model instance exists in the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, returns: {arg: 'exists', type: 'boolean'}, http: {verb: 'get', path: '/:id/exists'} }); - setRemoting(DataModel, 'findById', { + setRemoting(PersistedModel, 'findById', { description: 'Find a model instance by id from the data source', accepts: { arg: 'id', type: 'any', description: 'Model id', required: true, @@ -440,42 +440,42 @@ DataModel.setupRemoting = function() { rest: {after: convertNullToNotFoundError} }); - setRemoting(DataModel, 'find', { + setRemoting(PersistedModel, 'find', { description: 'Find all instances of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: [typeName], root: true}, http: {verb: 'get', path: '/'} }); - setRemoting(DataModel, 'findOne', { + setRemoting(PersistedModel, 'findOne', { description: 'Find first instance of the model matched by filter from the data source', accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, returns: {arg: 'data', type: typeName, root: true}, http: {verb: 'get', path: '/findOne'} }); - setRemoting(DataModel, 'destroyAll', { + setRemoting(PersistedModel, 'destroyAll', { description: 'Delete all matching records', accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, http: {verb: 'del', path: '/'}, shared: false }); - setRemoting(DataModel, 'removeById', { + setRemoting(PersistedModel, 'removeById', { description: 'Delete a model instance by id from the data source', accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, http: {source: 'path'}}, http: {verb: 'del', path: '/:id'} }); - setRemoting(DataModel, 'count', { + setRemoting(PersistedModel, 'count', { description: 'Count instances of the model matched by where from the data source', accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, returns: {arg: 'count', type: 'number'}, http: {verb: 'get', path: '/count'} }); - setRemoting(DataModel.prototype, 'updateAttributes', { + setRemoting(PersistedModel.prototype, 'updateAttributes', { description: 'Update attributes for a model instance and persist it into the data source', accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, returns: {arg: 'data', type: typeName, root: true}, @@ -483,7 +483,7 @@ DataModel.setupRemoting = function() { }); if(options.trackChanges) { - setRemoting(DataModel, 'diff', { + setRemoting(PersistedModel, 'diff', { description: 'Get a set of deltas and conflicts since the given checkpoint', accepts: [ {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, @@ -494,7 +494,7 @@ DataModel.setupRemoting = function() { http: {verb: 'post', path: '/diff'} }); - setRemoting(DataModel, 'changes', { + setRemoting(PersistedModel, 'changes', { description: 'Get the changes to a model since a given checkpoint.' + 'Provide a filter object to reduce the number of results returned.', accepts: [ @@ -505,37 +505,37 @@ DataModel.setupRemoting = function() { http: {verb: 'get', path: '/changes'} }); - setRemoting(DataModel, 'checkpoint', { + setRemoting(PersistedModel, 'checkpoint', { description: 'Create a checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'post', path: '/checkpoint'} }); - setRemoting(DataModel, 'currentCheckpoint', { + setRemoting(PersistedModel, 'currentCheckpoint', { description: 'Get the current checkpoint.', returns: {arg: 'checkpoint', type: 'object', root: true}, http: {verb: 'get', path: '/checkpoint'} }); - setRemoting(DataModel, 'createUpdates', { + setRemoting(PersistedModel, 'createUpdates', { description: 'Create an update list from a delta list', accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, returns: {arg: 'updates', type: 'array', root: true}, http: {verb: 'post', path: '/create-updates'} }); - setRemoting(DataModel, 'bulkUpdate', { + setRemoting(PersistedModel, 'bulkUpdate', { description: 'Run multiple updates at once. Note: this is not atomic.', accepts: {arg: 'updates', type: 'array'}, http: {verb: 'post', path: '/bulk-update'} }); - setRemoting(DataModel, 'rectifyAllChanges', { + setRemoting(PersistedModel, 'rectifyAllChanges', { description: 'Rectify all Model changes.', http: {verb: 'post', path: '/rectify-all'} }); - setRemoting(DataModel, 'rectifyChange', { + setRemoting(PersistedModel, 'rectifyChange', { description: 'Tell loopback that a change to the model with the given id has occurred.', accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, http: {verb: 'post', path: '/:id/rectify-change'} @@ -553,7 +553,7 @@ DataModel.setupRemoting = function() { * @param {Function} callback */ -DataModel.diff = function(since, remoteChanges, callback) { +PersistedModel.diff = function(since, remoteChanges, callback) { var Change = this.getChangeModel(); Change.diff(this.modelName, since, remoteChanges, callback); } @@ -570,7 +570,7 @@ DataModel.diff = function(since, remoteChanges, callback) { * @end */ -DataModel.changes = function(since, filter, callback) { +PersistedModel.changes = function(since, filter, callback) { if(typeof since === 'function') { filter = {}; callback = since; @@ -620,7 +620,7 @@ DataModel.changes = function(since, filter, callback) { * @param {Function} callback */ -DataModel.checkpoint = function(cb) { +PersistedModel.checkpoint = function(cb) { var Checkpoint = this.getChangeModel().getCheckpointModel(); this.getSourceId(function(err, sourceId) { if(err) return cb(err); @@ -639,7 +639,7 @@ DataModel.checkpoint = function(cb) { * @end */ -DataModel.currentCheckpoint = function(cb) { +PersistedModel.currentCheckpoint = function(cb) { var Checkpoint = this.getChangeModel().getCheckpointModel(); Checkpoint.current(cb); } @@ -657,7 +657,7 @@ DataModel.currentCheckpoint = function(cb) { * due to conflicts. */ -DataModel.replicate = function(since, targetModel, options, callback) { +PersistedModel.replicate = function(since, targetModel, options, callback) { var lastArg = arguments[arguments.length - 1]; if(typeof lastArg === 'function' && arguments.length > 1) { @@ -750,7 +750,7 @@ DataModel.replicate = function(since, targetModel, options, callback) { * @param {Function} callback */ -DataModel.createUpdates = function(deltas, cb) { +PersistedModel.createUpdates = function(deltas, cb) { var Change = this.getChangeModel(); var updates = []; var Model = this; @@ -802,7 +802,7 @@ DataModel.createUpdates = function(deltas, cb) { * @param {Function} callback */ -DataModel.bulkUpdate = function(updates, callback) { +PersistedModel.bulkUpdate = function(updates, callback) { var tasks = []; var Model = this; var idName = this.dataSource.idName(this.modelName); @@ -838,7 +838,7 @@ DataModel.bulkUpdate = function(updates, callback) { * @return {Change} */ -DataModel.getChangeModel = function() { +PersistedModel.getChangeModel = function() { var changeModel = this.Change; var isSetup = changeModel && changeModel.dataSource; @@ -855,7 +855,7 @@ DataModel.getChangeModel = function() { * @param {String} sourceId */ -DataModel.getSourceId = function(cb) { +PersistedModel.getSourceId = function(cb) { var dataSource = this.dataSource; if(!dataSource) { this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); @@ -872,7 +872,7 @@ DataModel.getSourceId = function(cb) { * Enable the tracking of changes made to the model. Usually for replication. */ -DataModel.enableChangeTracking = function() { +PersistedModel.enableChangeTracking = function() { var Model = this; var Change = this.Change || this._defineChangeModel(); var cleanupInterval = Model.settings.changeCleanupInterval || 30000; @@ -911,7 +911,7 @@ DataModel.enableChangeTracking = function() { } } -DataModel._defineChangeModel = function() { +PersistedModel._defineChangeModel = function() { var BaseChangeModel = require('./change'); return this.Change = BaseChangeModel.extend(this.modelName + '-change', {}, @@ -921,7 +921,7 @@ DataModel._defineChangeModel = function() { ); } -DataModel.rectifyAllChanges = function(callback) { +PersistedModel.rectifyAllChanges = function(callback) { this.getChangeModel().rectifyAll(callback); } @@ -932,7 +932,7 @@ DataModel.rectifyAllChanges = function(callback) { * @param {Error} err */ -DataModel.handleChangeError = function(err) { +PersistedModel.handleChangeError = function(err) { if(err) { console.error(Model.modelName + ' Change Tracking Error:'); console.error(err); @@ -947,9 +947,9 @@ DataModel.handleChangeError = function(err) { * @param {Error} err */ -DataModel.rectifyChange = function(id, callback) { +PersistedModel.rectifyChange = function(id, callback) { var Change = this.getChangeModel(); Change.rectifyModelChanges(this.modelName, [id], callback); } -DataModel.setup(); +PersistedModel.setup(); diff --git a/lib/models/user.js b/lib/models/user.js index ef612403b..ae3ad5699 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -2,7 +2,7 @@ * Module Dependencies. */ -var DataModel = require('../loopback').DataModel +var PersistedModel = require('../loopback').PersistedModel , loopback = require('../loopback') , path = require('path') , SALT_WORK_FACTOR = 10 @@ -126,7 +126,7 @@ var options = { * @inherits {Model} */ -var User = module.exports = DataModel.extend('User', properties, options); +var User = module.exports = PersistedModel.extend('User', properties, options); /** * Login a user by with the given `credentials`. @@ -415,7 +415,7 @@ User.resetPassword = function(options, cb) { User.setup = function () { // We need to call the base class's setup method - DataModel.setup.call(this); + PersistedModel.setup.call(this); var UserModel = this; // max ttl diff --git a/test/access-token.test.js b/test/access-token.test.js index cd4bc0d63..a9e47aab9 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -125,7 +125,7 @@ function createTestApp(testToken, done) { app.use(loopback.rest()); app.enableAuth(); - var TestModel = loopback.DataModel.extend('test', {}, { + var TestModel = loopback.PersistedModel.extend('test', {}, { acls: [ { principalType: "ROLE", diff --git a/test/acl.test.js b/test/acl.test.js index bf7bdb860..3f8892bdb 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -22,7 +22,7 @@ before(function() { describe('security scopes', function () { beforeEach(function() { var ds = this.ds = loopback.createDataSource({connector: loopback.Memory}); - testModel = loopback.DataModel.extend('testModel'); + testModel = loopback.PersistedModel.extend('testModel'); ACL.attachTo(ds); Role.attachTo(ds); RoleMapping.attachTo(ds); diff --git a/test/app.test.js b/test/app.test.js index 2ab4e287e..e14f861fc 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,7 +1,7 @@ var path = require('path'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); var loopback = require('../'); -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; var describe = require('./util/describe'); var it = require('./util/it'); @@ -16,7 +16,7 @@ describe('app', function() { }); it("Expose a `Model` to remote clients", function() { - var Color = DataModel.extend('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); Color.attachTo(db); @@ -24,14 +24,14 @@ describe('app', function() { }); it('uses singlar name as app.remoteObjects() key', function() { - var Color = DataModel.extend('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); Color.attachTo(db); expect(app.remoteObjects()).to.eql({ color: Color }); }); it('uses singular name as shared class name', function() { - var Color = DataModel.extend('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); Color.attachTo(db); var classes = app.remotes().classes().map(function(c) {return c.name}); @@ -42,7 +42,7 @@ describe('app', function() { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { if (err) return done(err); - var Color = DataModel.extend('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); Color.attachTo(db); request(app).get('/colors').expect(200, done); diff --git a/test/change.test.js b/test/change.test.js index e0c53eeba..4c34eace7 100644 --- a/test/change.test.js +++ b/test/change.test.js @@ -6,7 +6,7 @@ describe('Change', function(){ var memory = loopback.createDataSource({ connector: loopback.Memory }); - TestModel = loopback.DataModel.extend('chtest', {}, { + TestModel = loopback.PersistedModel.extend('chtest', {}, { trackChanges: true }); this.modelName = TestModel.modelName; diff --git a/test/data-source.test.js b/test/data-source.test.js index 552da12dd..7f09a1c42 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -36,9 +36,9 @@ describe('DataSource', function() { }); }); - describe.skip('DataModel Methods', function() { + describe.skip('PersistedModel Methods', function() { it("List the enabled and disabled methods", function() { - var TestModel = loopback.DataModel.extend('TestDataModel'); + var TestModel = loopback.PersistedModel.extend('TestPersistedModel'); TestModel.attachTo(loopback.memory()); // assert the defaults diff --git a/test/fixtures/e2e/models.js b/test/fixtures/e2e/models.js index e1c22d6e0..3574c5bce 100644 --- a/test/fixtures/e2e/models.js +++ b/test/fixtures/e2e/models.js @@ -1,6 +1,6 @@ var loopback = require('../../../'); -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; -exports.TestModel = DataModel.extend('TestModel', {}, { +exports.TestModel = PersistedModel.extend('TestModel', {}, { trackChanges: true }); diff --git a/test/hidden-properties.test.js b/test/hidden-properties.test.js index d1d8740ba..d30d32f7c 100644 --- a/test/hidden-properties.test.js +++ b/test/hidden-properties.test.js @@ -3,13 +3,13 @@ var loopback = require('../'); describe('hidden properties', function () { beforeEach(function (done) { var app = this.app = loopback(); - var Product = this.Product = loopback.DataModel.extend('product', + var Product = this.Product = loopback.PersistedModel.extend('product', {}, {hidden: ['secret']} ); Product.attachTo(loopback.memory()); - var Category = this.Category = loopback.DataModel.extend('category'); + var Category = this.Category = loopback.PersistedModel.extend('category'); Category.attachTo(loopback.memory()); Category.hasMany(Product); diff --git a/test/model.test.js b/test/model.test.js index 10d02a7b8..145f0185b 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -3,11 +3,11 @@ var loopback = require('../'); var ACL = loopback.ACL; var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; var describe = require('./util/describe'); -describe('Model / DataModel', function() { +describe('Model / PersistedModel', function() { defineModelTestsWithDataSource({ dataSource: { connector: loopback.Memory @@ -16,7 +16,7 @@ describe('Model / DataModel', function() { describe('Model.validatesUniquenessOf(property, options)', function() { it("Ensure the value for `property` is unique", function(done) { - var User = DataModel.extend('user', { + var User = PersistedModel.extend('user', { 'first': String, 'last': String, 'age': Number, @@ -71,7 +71,7 @@ describe.onServer('Remote Methods', function(){ var app; beforeEach(function () { - User = DataModel.extend('user', { + User = PersistedModel.extend('user', { 'first': String, 'last': String, 'age': Number, @@ -362,7 +362,7 @@ describe.onServer('Remote Methods', function(){ describe('Model.extend()', function(){ it('Create a new model by extending an existing model', function() { - var User = loopback.DataModel.extend('test-user', { + var User = loopback.PersistedModel.extend('test-user', { email: String }); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 129525b83..b0b311357 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -32,7 +32,7 @@ describe('RemoteConnector', function() { var test = this; remoteApp = this.remoteApp = loopback(); remoteApp.use(loopback.rest()); - var ServerModel = this.ServerModel = loopback.DataModel.extend('TestModel'); + var ServerModel = this.ServerModel = loopback.PersistedModel.extend('TestModel'); remoteApp.model(ServerModel); @@ -48,7 +48,7 @@ describe('RemoteConnector', function() { it('should support the save method', function (done) { var calledServerCreate = false; - var RemoteModel = loopback.DataModel.extend('TestModel'); + var RemoteModel = loopback.PersistedModel.extend('TestModel'); RemoteModel.attachTo(this.remote); var ServerModel = this.ServerModel; diff --git a/test/replication.test.js b/test/replication.test.js index c22b1396b..286535b32 100644 --- a/test/replication.test.js +++ b/test/replication.test.js @@ -3,7 +3,7 @@ var loopback = require('../'); var ACL = loopback.ACL; var Change = loopback.Change; var defineModelTestsWithDataSource = require('./util/model-tests'); -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; describe('Replication / Change APIs', function() { beforeEach(function() { @@ -11,12 +11,12 @@ describe('Replication / Change APIs', function() { var dataSource = this.dataSource = loopback.createDataSource({ connector: loopback.Memory }); - var SourceModel = this.SourceModel = DataModel.extend('SourceModel', {}, { + var SourceModel = this.SourceModel = PersistedModel.extend('SourceModel', {}, { trackChanges: true }); SourceModel.attachTo(dataSource); - var TargetModel = this.TargetModel = DataModel.extend('TargetModel', {}, { + var TargetModel = this.TargetModel = PersistedModel.extend('TargetModel', {}, { trackChanges: true }); TargetModel.attachTo(dataSource); diff --git a/test/util/model-tests.js b/test/util/model-tests.js index c8948aa64..a3362b58f 100644 --- a/test/util/model-tests.js +++ b/test/util/model-tests.js @@ -3,7 +3,7 @@ var describe = require('./describe'); var loopback = require('../../'); var ACL = loopback.ACL; var Change = loopback.Change; -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; var RemoteObjects = require('strong-remoting'); module.exports = function defineModelTestsWithDataSource(options) { @@ -22,11 +22,11 @@ describe('Model Tests', function() { // setup a model / datasource dataSource = this.dataSource || loopback.createDataSource(options.dataSource); - var extend = DataModel.extend; + var extend = PersistedModel.extend; // create model hook - DataModel.extend = function() { - var extendedModel = extend.apply(DataModel, arguments); + PersistedModel.extend = function() { + var extendedModel = extend.apply(PersistedModel, arguments); if(options.onDefine) { options.onDefine.call(test, extendedModel); @@ -35,7 +35,7 @@ describe('Model Tests', function() { return extendedModel; } - User = DataModel.extend('user', { + User = PersistedModel.extend('user', { 'first': String, 'last': String, 'age': Number, From f8444593117ed956056afe7985423ec72df3ba39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 5 Jun 2014 17:41:12 +0200 Subject: [PATCH 51/74] Add createModelFromConfig and configureModel() Add new API allowing developers to split the model definition and configuration into two steps: 1. Build models from JSON config, export them for re-use: ```js var Customer = loopback.createModelFromConfig({ name: 'Customer', base: 'User', properties: { address: 'string' } }); ``` 2. Attach existing models to a dataSource and a loopback app, modify certain model aspects like relations: ```js loopback.configureModel(Customer, { dataSource: db, relations: { /* ... */ } }); ``` Rework `app.model` to use `loopback.configureModel` under the hood. Here is the new usage: ```js var Customer = require('./models').Customer; app.model(Customer, { dataSource: 'db', relations: { /* ... */ } }); ``` In order to preserve backwards compatibility with loopback 1.x, `app.model(name, config)` calls both `createModelFromConfig` and `configureModel`. --- lib/application.js | 118 ++++++++++++++++++++++++------------------ lib/loopback.js | 89 ++++++++++++++++++++++++++++++- test/app.test.js | 14 +++++ test/loopback.test.js | 98 +++++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 52 deletions(-) diff --git a/lib/application.js b/lib/application.js index 4059de2bf..4197d102a 100644 --- a/lib/application.js +++ b/lib/application.js @@ -3,7 +3,7 @@ */ var DataSource = require('loopback-datasource-juggler').DataSource - , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder + , loopback = require('../') , compat = require('./compat') , assert = require('assert') , fs = require('fs') @@ -82,33 +82,40 @@ app.disuse = function (route) { } /** - * Define and attach a model to the app. The `Model` will be available on the + * Attach a model to the app. The `Model` will be available on the * `app.models` object. * * ```js - * var Widget = app.model('Widget', {dataSource: 'db'}); - * Widget.create({name: 'pencil'}); - * app.models.Widget.find(function(err, widgets) { - * console.log(widgets[0]); // => {name: 'pencil'} + * // Attach an existing model + * var User = loopback.User; + * app.model(User); + * + * // Attach an existing model, alter some aspects of the model + * var User = loopback.User; + * app.model(User, { dataSource: 'db' }); + * + * // LoopBack 1.x way: create and attach a new model (deprecated) + * var Widget = app.model('Widget', { + * dataSource: 'db', + * properties: { + * name: 'string' + * } * }); * ``` * - * @param {String} modelName The name of the model to define. + * @param {Object|String} Model The model to attach. * @options {Object} config The model's configuration. - * @property {String|DataSource} dataSource The `DataSource` to which to attach the model. - * @property {Object} [options] an object containing `Model` options. - * @property {ACL[]} [options.acls] an array of `ACL` definitions. - * @property {String[]} [options.hidden] **experimental** an array of properties to hide when accessed remotely. - * @property {Object} [properties] object defining the `Model` properties in [LoopBack Definition Language](http://docs.strongloop.com/loopback-datasource-juggler/#loopback-definition-language). + * @property {String|DataSource} dataSource The `DataSource` to which to + * attach the model. + * @property {Boolean} [public] whether the model should be exposed via REST API + * @property {Object} [relations] relations to add/update * @end * @returns {ModelConstructor} the model class */ app.model = function (Model, config) { if(arguments.length === 1) { - assert(typeof Model === 'function', 'app.model(Model) => Model must be a function / constructor'); - assert(Model.modelName, 'Model must have a "modelName" property'); - var remotingClassName = compat.getClassNameForRemoting(Model); + assertIsModel(Model); if(Model.sharedClass) { this.remotes().addClass(Model.sharedClass); } @@ -117,22 +124,53 @@ app.model = function (Model, config) { Model.shared = true; Model.app = this; Model.emit('attached', this); - return; + return Model; } - var modelName = Model; + config = config || {}; - assert(typeof modelName === 'string', 'app.model(name, config) => "name" name must be a string'); - Model = + if (typeof Model === 'string') { + // create & attach the model - loopback 1.x compatibility + + // create config for loopback.modelFromConfig + var modelConfig = extend({}, config); + modelConfig.options = extend({}, config.options); + modelConfig.name = Model; + + // modeller does not understand `dataSource` option + delete modelConfig.dataSource; + + Model = loopback.createModelFromConfig(modelConfig); + + // delete config options already applied + ['relations', 'base', 'acls', 'hidden'].forEach(function(prop) { + delete config[prop]; + if (config.options) delete config.options[prop]; + }); + delete config.properties; + } + + configureModel(Model, config, this); + + var modelName = Model.modelName; this.models[modelName] = this.models[classify(modelName)] = - this.models[camelize(modelName)] = modelFromConfig(modelName, config, this); + this.models[camelize(modelName)] = Model; - if(config.public !== false) { + if (config.public !== false) { this.model(Model); } return Model; +}; + + +function assertIsModel(Model) { + assert(typeof Model === 'function', + 'Model must be a function / constructor'); + assert(Model.modelName, 'Model must have a "modelName" property'); + assert(Model.prototype instanceof loopback.Model, + 'Model must be a descendant of loopback.Model'); } /** @@ -566,41 +604,23 @@ function dataSourcesFromConfig(config, connectorRegistry) { return require('./loopback').createDataSource(config); } -function modelFromConfig(name, config, app) { - var options = buildModelOptionsFromConfig(config); - var properties = config.properties; +function configureModel(ModelCtor, config, app) { + assertIsModel(ModelCtor); - var ModelCtor = require('./loopback').createModel(name, properties, options); var dataSource = config.dataSource; if(typeof dataSource === 'string') { dataSource = app.dataSources[dataSource]; } - assert(isDataSource(dataSource), name + ' is referencing a dataSource that does not exist: "'+ config.dataSource +'"'); - - ModelCtor.attachTo(dataSource); - return ModelCtor; -} - -function buildModelOptionsFromConfig(config) { - var options = extend({}, config.options); - for (var key in config) { - if (['properties', 'options', 'dataSource'].indexOf(key) !== -1) { - // Skip items which have special meaning - continue; - } + assert(isDataSource(dataSource), + ModelCtor.modelName + ' is referencing a dataSource that does not exist: "' + + config.dataSource +'"'); - if (options[key] !== undefined) { - // When both `config.key` and `config.options.key` are set, - // use the latter one to preserve backwards compatibility - // with loopback 1.x - continue; - } + config = extend({}, config); + config.dataSource = dataSource; - options[key] = config[key]; - } - return options; + loopback.configureModel(ModelCtor, config); } function requireDir(dir, basenames) { @@ -672,10 +692,6 @@ function tryReadDir() { } } -function isModelCtor(obj) { - return typeof obj === 'function' && obj.modelName && obj.name === 'ModelCtor'; -} - function isDataSource(obj) { return obj instanceof DataSource; } diff --git a/lib/loopback.js b/lib/loopback.js index 4f9859ca3..1d6b1e619 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -6,7 +6,6 @@ var express = require('express') , fs = require('fs') , ejs = require('ejs') , path = require('path') - , proto = require('./application') , DataSource = require('loopback-datasource-juggler').DataSource , merge = require('util')._extend , assert = require('assert'); @@ -66,6 +65,9 @@ loopback.compat = require('./compat'); function createApplication() { var app = express(); + // Defer loading of `./application` until all `loopback` static methods + // are defined, because `./application` depends on loopback. + var proto = require('./application'); merge(app, proto); // Create a new instance of models registry per each app instance @@ -193,6 +195,91 @@ loopback.createModel = function (name, properties, options) { return model; }; +/** + * Create a model as described by the configuration object. + * + * @example + * + * ```js + * loopback.createModelFromConfig({ + * name: 'Author', + * properties: { + * firstName: 'string', + * lastName: 'string + * }, + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * }); + * ``` + * + * @options {Object} model configuration + * @property {String} name Unique name. + * @property {Object=} properties Model properties + * @property {Object=} options Model options. Options can be specified on the + * top level config object too. E.g. `{ base: 'User' }` is the same as + * `{ options: { base: 'User' } }`. + */ +loopback.createModelFromConfig = function(config) { + var name = config.name; + var properties = config.properties; + var options = buildModelOptionsFromConfig(config); + + assert(typeof name === 'string', + 'The model-config property `name` must be a string'); + + return loopback.createModel(name, properties, options); +}; + +function buildModelOptionsFromConfig(config) { + var options = merge({}, config.options); + for (var key in config) { + if (['name', 'properties', 'options'].indexOf(key) !== -1) { + // Skip items which have special meaning + continue; + } + + if (options[key] !== undefined) { + // When both `config.key` and `config.options.key` are set, + // use the latter one + continue; + } + + options[key] = config[key]; + } + return options; +} + +/** + * Alter an existing Model class. + * @param {Model} ModelCtor The model constructor to alter. + * @options {Object} Additional configuration to apply + * @property {DataSource} dataSource Attach the model to a dataSource. + * @property {Object} relations Model relations to add/update. + */ +loopback.configureModel = function(ModelCtor, config) { + var settings = ModelCtor.settings; + + if (config.relations) { + var relations = settings.relations = settings.relations || {}; + Object.keys(config.relations).forEach(function(key) { + relations[key] = merge(relations[key] || {}, config.relations[key]); + }); + } + + // It's important to attach the datasource after we have updated + // configuration, so that the datasource picks up updated relations + if (config.dataSource) { + assert(config.dataSource instanceof DataSource, + 'Cannot configure ' + ModelCtor.modelName + + ': config.dataSource must be an instance of loopback.DataSource'); + ModelCtor.attachTo(config.dataSource); + } +}; + /** * Add a remote method to a model. * @param {Function} fn diff --git a/test/app.test.js b/test/app.test.js index 2ab4e287e..dec96af65 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -122,6 +122,20 @@ describe('app', function() { }); }); + describe('app.model(ModelCtor, config)', function() { + it('attaches the model to a datasource', function() { + app.dataSource('db', { connector: 'memory' }); + var TestModel = loopback.Model.extend('TestModel'); + // TestModel was most likely already defined in a different test, + // thus TestModel.dataSource may be already set + delete TestModel.dataSource; + + app.model(TestModel, { dataSource: 'db' }); + + expect(app.models.TestModel.dataSource).to.equal(app.dataSources.db); + }); + }); + describe('app.models', function() { it('is unique per app instance', function() { app.dataSource('db', { connector: 'memory' }); diff --git a/test/loopback.test.js b/test/loopback.test.js index 7988fdb43..5cb280989 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -1,4 +1,11 @@ describe('loopback', function() { + var nameCounter = 0; + var uniqueModelName; + + beforeEach(function() { + uniqueModelName = 'TestModel-' + (++nameCounter); + }); + describe('exports', function() { it('ValidationError', function() { expect(loopback.ValidationError).to.be.a('function') @@ -119,4 +126,95 @@ describe('loopback', function() { }); }); }); + + describe('loopback.createModelFromConfig(config)', function() { + it('creates the model', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName + }); + + expect(model.prototype).to.be.instanceof(loopback.Model); + }); + + it('interprets extra first-level keys as options', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName, + base: 'User' + }); + + expect(model.prototype).to.be.instanceof(loopback.User); + }); + + it('prefers config.options.key over config.key', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName, + base: 'User', + options: { + base: 'Application' + } + }); + + expect(model.prototype).to.be.instanceof(loopback.Application); + }); + }); + + describe('loopback.configureModel(ModelCtor, config)', function() { + it('adds new relations', function() { + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + expect(model.settings.relations).to.have.property('owner'); + }); + + it('updates existing relations', function() { + var model = loopback.Model.extend(uniqueModelName, {}, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + loopback.configureModel(model, { + relations: { + owner: { + model: 'Application' + } + } + }); + + expect(model.settings.relations.owner).to.eql({ + type: 'belongsTo', + model: 'Application' + }); + }); + + it('updates relations before attaching to a dataSource', function() { + var db = loopback.createDataSource({ connector: loopback.Memory }); + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + dataSource: db, + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + var owner = model.prototype.owner; + expect(owner, 'model.prototype.owner').to.be.a('function'); + expect(owner._targetClass).to.equal('User'); + }); + }); }); From 51e977de9bf101202cbcd3a1367fa07d96ff5be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Jun 2014 10:28:21 +0200 Subject: [PATCH 52/74] lib/loopback: fix jsdoc comments Use @property {Object} [properties] instead of @property {Object=} properties for optional properties. Use `**example**` instead of `@example`, since strong-docs don't support the latter. --- lib/loopback.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/loopback.js b/lib/loopback.js index d360c0e2e..85c414930 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -210,7 +210,7 @@ loopback.createModel = function (name, properties, options) { /** * Create a model as described by the configuration object. * - * @example + * **Example** * * ```js * loopback.createModelFromConfig({ @@ -230,8 +230,8 @@ loopback.createModel = function (name, properties, options) { * * @options {Object} model configuration * @property {String} name Unique name. - * @property {Object=} properties Model properties - * @property {Object=} options Model options. Options can be specified on the + * @property {Object} [properties] Model properties + * @property {Object} [options] Model options. Options can be specified on the * top level config object too. E.g. `{ base: 'User' }` is the same as * `{ options: { base: 'User' } }`. */ @@ -270,7 +270,7 @@ function buildModelOptionsFromConfig(config) { * @param {Model} ModelCtor The model constructor to alter. * @options {Object} Additional configuration to apply * @property {DataSource} dataSource Attach the model to a dataSource. - * @property {Object} relations Model relations to add/update. + * @property {Object} [relations] Model relations to add/update. */ loopback.configureModel = function(ModelCtor, config) { var settings = ModelCtor.settings; From 1b1106099187c0b23d622410feb4823b01729142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Jun 2014 10:30:03 +0200 Subject: [PATCH 53/74] Remove assertIsModel and isDataSource Use `instanceof` operator instead: ModelCtor.prototype instanceof loopback.Model dataSource instanceof loopback.DataSource --- lib/application.js | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/lib/application.js b/lib/application.js index 4197d102a..2f972f3ce 100644 --- a/lib/application.js +++ b/lib/application.js @@ -115,7 +115,8 @@ app.disuse = function (route) { app.model = function (Model, config) { if(arguments.length === 1) { - assertIsModel(Model); + assert(Model.prototype instanceof loopback.Model, + 'Model must be a descendant of loopback.Model'); if(Model.sharedClass) { this.remotes().addClass(Model.sharedClass); } @@ -165,14 +166,6 @@ app.model = function (Model, config) { }; -function assertIsModel(Model) { - assert(typeof Model === 'function', - 'Model must be a function / constructor'); - assert(Model.modelName, 'Model must have a "modelName" property'); - assert(Model.prototype instanceof loopback.Model, - 'Model must be a descendant of loopback.Model'); -} - /** * Get the models exported by the app. Returns only models defined using `app.model()` * @@ -605,7 +598,8 @@ function dataSourcesFromConfig(config, connectorRegistry) { } function configureModel(ModelCtor, config, app) { - assertIsModel(ModelCtor); + assert(ModelCtor.prototype instanceof loopback.Model, + 'Model must be a descendant of loopback.Model'); var dataSource = config.dataSource; @@ -613,7 +607,7 @@ function configureModel(ModelCtor, config, app) { dataSource = app.dataSources[dataSource]; } - assert(isDataSource(dataSource), + assert(dataSource instanceof DataSource, ModelCtor.modelName + ' is referencing a dataSource that does not exist: "' + config.dataSource +'"'); @@ -692,10 +686,6 @@ function tryReadDir() { } } -function isDataSource(obj) { - return obj instanceof DataSource; -} - function tryReadConfig(cwd, fileName) { try { return require(path.join(cwd, fileName + '.json')); From eac231df99943c31bd1e77697c2941ebe1bdd5a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Jun 2014 11:47:25 +0200 Subject: [PATCH 54/74] refactor: extract runtime and registry Move isBrowser and isServer from lib/loopback to a new file lib/runtime. Move all Model and DataSource related methods like `createModel` and `createDataSource` to lib/registry. Remove the circular dependency between lib/application and lib/loopback, by loading lib/registry and/or lib/runtime instead of lib/loopback where appropriate This commit is only moving the code around, the functionality should not be changed at all. --- docs.json | 2 + lib/application.js | 14 +- lib/loopback.js | 319 ++-------------------------------- lib/models/model.js | 4 +- lib/models/persisted-model.js | 4 +- lib/registry.js | 318 +++++++++++++++++++++++++++++++++ lib/runtime.js | 22 +++ 7 files changed, 366 insertions(+), 317 deletions(-) create mode 100644 lib/registry.js create mode 100644 lib/runtime.js diff --git a/docs.json b/docs.json index 6df5005f7..5822b1534 100644 --- a/docs.json +++ b/docs.json @@ -3,6 +3,8 @@ "content": [ "lib/application.js", "lib/loopback.js", + "lib/runtime.js", + "lib/registry.js", "lib/middleware/token.js", "lib/models/access-token.js", "lib/models/access-context.js", diff --git a/lib/application.js b/lib/application.js index 2f972f3ce..88f0f1ffc 100644 --- a/lib/application.js +++ b/lib/application.js @@ -3,7 +3,7 @@ */ var DataSource = require('loopback-datasource-juggler').DataSource - , loopback = require('../') + , registry = require('./registry') , compat = require('./compat') , assert = require('assert') , fs = require('fs') @@ -115,7 +115,7 @@ app.disuse = function (route) { app.model = function (Model, config) { if(arguments.length === 1) { - assert(Model.prototype instanceof loopback.Model, + assert(Model.prototype instanceof registry.Model, 'Model must be a descendant of loopback.Model'); if(Model.sharedClass) { this.remotes().addClass(Model.sharedClass); @@ -141,7 +141,7 @@ app.model = function (Model, config) { // modeller does not understand `dataSource` option delete modelConfig.dataSource; - Model = loopback.createModelFromConfig(modelConfig); + Model = registry.createModelFromConfig(modelConfig); // delete config options already applied ['relations', 'base', 'acls', 'hidden'].forEach(function(prop) { @@ -531,7 +531,7 @@ app.boot = function(options) { // try to attach models to dataSources by type try { - require('./loopback').autoAttach(); + registry.autoAttach(); } catch(e) { if(e.name === 'AssertionError') { console.warn(e); @@ -594,11 +594,11 @@ function dataSourcesFromConfig(config, connectorRegistry) { } } - return require('./loopback').createDataSource(config); + return registry.createDataSource(config); } function configureModel(ModelCtor, config, app) { - assert(ModelCtor.prototype instanceof loopback.Model, + assert(ModelCtor.prototype instanceof registry.Model, 'Model must be a descendant of loopback.Model'); var dataSource = config.dataSource; @@ -614,7 +614,7 @@ function configureModel(ModelCtor, config, app) { config = extend({}, config); config.dataSource = dataSource; - loopback.configureModel(ModelCtor, config); + registry.configureModel(ModelCtor, config); } function requireDir(dir, basenames) { diff --git a/lib/loopback.js b/lib/loopback.js index 85c414930..12d46314b 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -3,10 +3,10 @@ */ var express = require('express') + , proto = require('./application') , fs = require('fs') , ejs = require('ejs') , path = require('path') - , DataSource = require('loopback-datasource-juggler').DataSource , merge = require('util')._extend , assert = require('assert'); @@ -26,24 +26,6 @@ var express = require('express') var loopback = exports = module.exports = createApplication; -/** - * True if running in a browser environment; false otherwise. - */ - -loopback.isBrowser = typeof window !== 'undefined'; - -/** - * True if running in a server environment; false otherwise. - */ - -loopback.isServer = !loopback.isBrowser; - -/** - * Framework version. - */ - -loopback.version = require('../package.json').version; - /** * Expose mime. */ @@ -65,9 +47,6 @@ loopback.compat = require('./compat'); function createApplication() { var app = express(); - // Defer loading of `./application` until all `loopback` static methods - // are defined, because `./application` depends on loopback. - var proto = require('./application'); merge(app, proto); // Create a new instance of models registry per each app instance @@ -90,16 +69,21 @@ function createApplication() { return app; } +function mixin(source) { + for (var key in source) { + var desc = Object.getOwnPropertyDescriptor(source, key); + Object.defineProperty(loopback, key, desc); + } +} + +mixin(require('./runtime')); +mixin(require('./registry')); + /*! * Expose static express methods like `express.errorHandler`. */ -for (var key in express) { - Object.defineProperty( - loopback - , key - , Object.getOwnPropertyDescriptor(express, key)); -} +mixin(express); /*! * Expose additional middleware like session as loopback.* @@ -110,13 +94,7 @@ for (var key in express) { if (loopback.isServer) { var middlewares = require('./express-middleware'); - - for (var key in middlewares) { - Object.defineProperty( - loopback - , key - , Object.getOwnPropertyDescriptor(middlewares, key)); - } + mixin(middlewares); } /*! @@ -143,155 +121,6 @@ if (loopback.isServer) { loopback.errorHandler.title = 'Loopback'; -/** - * Create a data source with passing the provided options to the connector. - * - * @param {String} name Optional name. - * @options {Object} Data Source options - * @property {Object} connector LoopBack connector. - * @property {*} Other properties See the relevant connector documentation. - */ - -loopback.createDataSource = function (name, options) { - var ds = new DataSource(name, options, loopback.Model.modelBuilder); - ds.createModel = function (name, properties, settings) { - var ModelCtor = loopback.createModel(name, properties, settings); - ModelCtor.attachTo(ds); - return ModelCtor; - }; - - if(ds.settings && ds.settings.defaultForType) { - loopback.setDefaultDataSourceForType(ds.settings.defaultForType, ds); - } - - return ds; -}; - -/** - * Create a named vanilla JavaScript class constructor with an attached set of properties and options. - * - * @param {String} name Unique name. - * @param {Object} properties - * @param {Object} options (optional) - */ - -loopback.createModel = function (name, properties, options) { - options = options || {}; - var BaseModel = options.base || options.super; - - if(typeof BaseModel === 'string') { - var baseName = BaseModel; - BaseModel = loopback.getModel(BaseModel); - - if (BaseModel === undefined) { - if (baseName === 'DataModel') { - console.warn('Model `%s` is extending deprecated `DataModel. ' + - 'Use `PeristedModel` instead.', name); - BaseModel = loopback.PersistedModel; - } else { - console.warn('Model `%s` is extending an unknown model `%s`. ' + - 'Using `PersistedModel` as the base.', name, baseName); - } - } - } - - BaseModel = BaseModel || loopback.PersistedModel; - - var model = BaseModel.extend(name, properties, options); - - // try to attach - try { - loopback.autoAttachModel(model); - } catch(e) {} - - return model; -}; - -/** - * Create a model as described by the configuration object. - * - * **Example** - * - * ```js - * loopback.createModelFromConfig({ - * name: 'Author', - * properties: { - * firstName: 'string', - * lastName: 'string - * }, - * relations: { - * books: { - * model: 'Book', - * type: 'hasAndBelongsToMany' - * } - * } - * }); - * ``` - * - * @options {Object} model configuration - * @property {String} name Unique name. - * @property {Object} [properties] Model properties - * @property {Object} [options] Model options. Options can be specified on the - * top level config object too. E.g. `{ base: 'User' }` is the same as - * `{ options: { base: 'User' } }`. - */ -loopback.createModelFromConfig = function(config) { - var name = config.name; - var properties = config.properties; - var options = buildModelOptionsFromConfig(config); - - assert(typeof name === 'string', - 'The model-config property `name` must be a string'); - - return loopback.createModel(name, properties, options); -}; - -function buildModelOptionsFromConfig(config) { - var options = merge({}, config.options); - for (var key in config) { - if (['name', 'properties', 'options'].indexOf(key) !== -1) { - // Skip items which have special meaning - continue; - } - - if (options[key] !== undefined) { - // When both `config.key` and `config.options.key` are set, - // use the latter one - continue; - } - - options[key] = config[key]; - } - return options; -} - -/** - * Alter an existing Model class. - * @param {Model} ModelCtor The model constructor to alter. - * @options {Object} Additional configuration to apply - * @property {DataSource} dataSource Attach the model to a dataSource. - * @property {Object} [relations] Model relations to add/update. - */ -loopback.configureModel = function(ModelCtor, config) { - var settings = ModelCtor.settings; - - if (config.relations) { - var relations = settings.relations = settings.relations || {}; - Object.keys(config.relations).forEach(function(key) { - relations[key] = merge(relations[key] || {}, config.relations[key]); - }); - } - - // It's important to attach the datasource after we have updated - // configuration, so that the datasource picks up updated relations - if (config.dataSource) { - assert(config.dataSource instanceof DataSource, - 'Cannot configure ' + ModelCtor.modelName + - ': config.dataSource must be an instance of loopback.DataSource'); - ModelCtor.attachTo(config.dataSource); - } -}; - /** * Add a remote method to a model. * @param {Function} fn @@ -324,132 +153,10 @@ loopback.template = function (file) { return ejs.compile(str); }; -/** - * Get an in-memory data source. Use one if it already exists. - * - * @param {String} [name] The name of the data source. If not provided, the `'default'` is used. - */ - -loopback.memory = function (name) { - name = name || 'default'; - var memory = ( - this._memoryDataSources - || (this._memoryDataSources = {}) - )[name]; - - if(!memory) { - memory = this._memoryDataSources[name] = loopback.createDataSource({ - connector: loopback.Memory - }); - } - - return memory; -}; - -/** - * Look up a model class by name from all models created by loopback.createModel() - * @param {String} modelName The model name - * @returns {Model} The model class - */ -loopback.getModel = function(modelName) { - return loopback.Model.modelBuilder.models[modelName]; -}; - -/** - * Look up a model class by the base model class. The method can be used by LoopBack - * to find configured models in models.json over the base model. - * @param {Model} The base model class - * @returns {Model} The subclass if found or the base class - */ -loopback.getModelByType = function(modelType) { - assert(typeof modelType === 'function', 'The model type must be a constructor'); - var models = loopback.Model.modelBuilder.models; - for(var m in models) { - if(models[m].prototype instanceof modelType) { - return models[m]; - } - } - return modelType; -}; - -/** - * Set the default `dataSource` for a given `type`. - * @param {String} type The datasource type - * @param {Object|DataSource} dataSource The data source settings or instance - * @returns {DataSource} The data source instance - */ - -loopback.setDefaultDataSourceForType = function(type, dataSource) { - var defaultDataSources = this.defaultDataSources || (this.defaultDataSources = {}); - - if(!(dataSource instanceof DataSource)) { - dataSource = this.createDataSource(dataSource); - } - - defaultDataSources[type] = dataSource; - return dataSource; -}; - -/** - * Get the default `dataSource` for a given `type`. - * @param {String} type The datasource type - * @returns {DataSource} The data source instance - */ - -loopback.getDefaultDataSourceForType = function(type) { - return this.defaultDataSources && this.defaultDataSources[type]; -}; - -/** - * Attach any model that does not have a dataSource to - * the default dataSource for the type the Model requests - */ - -loopback.autoAttach = function() { - var models = this.Model.modelBuilder.models; - assert.equal(typeof models, 'object', 'Cannot autoAttach without a models object'); - - Object.keys(models).forEach(function(modelName) { - var ModelCtor = models[modelName]; - - // Only auto attach if the model doesn't have an explicit data source - if(ModelCtor && (!(ModelCtor.dataSource instanceof DataSource))) { - loopback.autoAttachModel(ModelCtor); - } - }); -}; - -loopback.autoAttachModel = function(ModelCtor) { - if(ModelCtor.autoAttach) { - var ds = loopback.getDefaultDataSourceForType(ModelCtor.autoAttach); - - assert(ds instanceof DataSource, 'cannot autoAttach model "' - + ModelCtor.modelName - + '". No dataSource found of type ' + ModelCtor.autoAttach); - - ModelCtor.attachTo(ds); - } -}; - /*! * Built in models / services */ -loopback.Model = require('./models/model'); -loopback.PersistedModel = require('./models/persisted-model'); - -// temporary alias to simplify migration of code based on <=2.0.0-beta3 -Object.defineProperty(loopback, 'DataModel', { - get: function() { - var stackLines = new Error().stack.split('\n'); - console.warn('loopback.DataModel is deprecated, ' + - 'use loopback.PersistedModel instead.'); - // Log the location where loopback.DataModel was called - console.warn(stackLines[2]); - return loopback.PersistedModel; - } -}); - loopback.Email = require('./models/email'); loopback.User = require('./models/user'); loopback.Application = require('./models/application'); diff --git a/lib/models/model.js b/lib/models/model.js index 374cd43ef..fa94aeaab 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -1,7 +1,7 @@ /*! * Module Dependencies. */ -var loopback = require('../loopback'); +var registry = require('../registry'); var compat = require('../compat'); var juggler = require('loopback-datasource-juggler'); var ModelBuilder = juggler.ModelBuilder; @@ -218,7 +218,7 @@ Model._ACL = function getACL(ACL) { return _aclModel; } var aclModel = require('./acl').ACL; - _aclModel = loopback.getModelByType(aclModel); + _aclModel = registry.getModelByType(aclModel); return _aclModel; }; diff --git a/lib/models/persisted-model.js b/lib/models/persisted-model.js index 540eb29b6..020398d1a 100644 --- a/lib/models/persisted-model.js +++ b/lib/models/persisted-model.js @@ -3,7 +3,7 @@ */ var Model = require('./model'); -var loopback = require('../loopback'); +var runtime = require('../runtime'); var RemoteObjects = require('strong-remoting'); var assert = require('assert'); var async = require('async'); @@ -893,7 +893,7 @@ PersistedModel.enableChangeTracking = function() { Model.on('deletedAll', cleanup); - if(loopback.isServer) { + if(runtime.isServer) { // initial cleanup cleanup(); diff --git a/lib/registry.js b/lib/registry.js new file mode 100644 index 000000000..bb905da23 --- /dev/null +++ b/lib/registry.js @@ -0,0 +1,318 @@ +/* + * This file exports methods and objects for manipulating + * Models and DataSources. + * + * It is an internal file that should not be used outside of loopback. + * All exported entities can be accessed via the `loopback` object. + * @private + */ + +var assert = require('assert'); +var extend = require('util')._extend; +var DataSource = require('loopback-datasource-juggler').DataSource; + +var registry = module.exports; + + +/** + * Create a named vanilla JavaScript class constructor with an attached + * set of properties and options. + * + * @param {String} name Unique name. + * @param {Object} properties + * @param {Object} options (optional) + * + * @header loopback.createModel + */ + +registry.createModel = function (name, properties, options) { + options = options || {}; + var BaseModel = options.base || options.super; + + if(typeof BaseModel === 'string') { + var baseName = BaseModel; + BaseModel = this.getModel(BaseModel); + + if (BaseModel === undefined) { + if (baseName === 'DataModel') { + console.warn('Model `%s` is extending deprecated `DataModel. ' + + 'Use `PeristedModel` instead.', name); + BaseModel = this.PersistedModel; + } else { + console.warn('Model `%s` is extending an unknown model `%s`. ' + + 'Using `PersistedModel` as the base.', name, baseName); + } + } + } + + BaseModel = BaseModel || this.PersistedModel; + + var model = BaseModel.extend(name, properties, options); + + // try to attach + try { + this.autoAttachModel(model); + } catch(e) {} + + return model; +}; + +/** + * Create a model as described by the configuration object. + * + * **Example** + * + * ```js + * loopback.createModelFromConfig({ + * name: 'Author', + * properties: { + * firstName: 'string', + * lastName: 'string + * }, + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * }); + * ``` + * + * @options {Object} model configuration + * @property {String} name Unique name. + * @property {Object} [properties] Model properties + * @property {Object} [options] Model options. Options can be specified on the + * top level config object too. E.g. `{ base: 'User' }` is the same as + * `{ options: { base: 'User' } }`. + * + * @header loopback.createModelFromConfig(config) + */ + +registry.createModelFromConfig = function(config) { + var name = config.name; + var properties = config.properties; + var options = buildModelOptionsFromConfig(config); + + assert(typeof name === 'string', + 'The model-config property `name` must be a string'); + + return this.createModel(name, properties, options); +}; + +function buildModelOptionsFromConfig(config) { + var options = extend({}, config.options); + for (var key in config) { + if (['name', 'properties', 'options'].indexOf(key) !== -1) { + // Skip items which have special meaning + continue; + } + + if (options[key] !== undefined) { + // When both `config.key` and `config.options.key` are set, + // use the latter one + continue; + } + + options[key] = config[key]; + } + return options; +} + +/** + * Alter an existing Model class. + * @param {Model} ModelCtor The model constructor to alter. + * @options {Object} Additional configuration to apply + * @property {DataSource} dataSource Attach the model to a dataSource. + * @property {Object} [relations] Model relations to add/update. + * + * @header loopback.configureModel(ModelCtor, config) + */ + +registry.configureModel = function(ModelCtor, config) { + var settings = ModelCtor.settings; + + if (config.relations) { + var relations = settings.relations = settings.relations || {}; + Object.keys(config.relations).forEach(function(key) { + relations[key] = extend(relations[key] || {}, config.relations[key]); + }); + } + + // It's important to attach the datasource after we have updated + // configuration, so that the datasource picks up updated relations + if (config.dataSource) { + assert(config.dataSource instanceof DataSource, + 'Cannot configure ' + ModelCtor.modelName + + ': config.dataSource must be an instance of DataSource'); + ModelCtor.attachTo(config.dataSource); + } +}; + +/** + * Look up a model class by name from all models created by + * `loopback.createModel()` + * @param {String} modelName The model name + * @returns {Model} The model class + * + * @header loopback.getModel(modelName) + */ +registry.getModel = function(modelName) { + return this.Model.modelBuilder.models[modelName]; +}; + +/** + * Look up a model class by the base model class. + * The method can be used by LoopBack + * to find configured models in models.json over the base model. + * @param {Model} modelType The base model class + * @returns {Model} The subclass if found or the base class + * + * @header loopback.getModelByType(modelType) + */ +registry.getModelByType = function(modelType) { + assert(typeof modelType === 'function', + 'The model type must be a constructor'); + var models = this.Model.modelBuilder.models; + for(var m in models) { + if(models[m].prototype instanceof modelType) { + return models[m]; + } + } + return modelType; +}; + +/** + * Create a data source with passing the provided options to the connector. + * + * @param {String} name Optional name. + * @options {Object} Data Source options + * @property {Object} connector LoopBack connector. + * @property {*} Other properties See the relevant connector documentation. + * + * @header loopback.createDataSource(name, options) + */ + +registry.createDataSource = function (name, options) { + var loopback = this; + var ds = new DataSource(name, options, loopback.Model.modelBuilder); + ds.createModel = function (name, properties, settings) { + var ModelCtor = loopback.createModel(name, properties, settings); + ModelCtor.attachTo(ds); + return ModelCtor; + }; + + if(ds.settings && ds.settings.defaultForType) { + this.setDefaultDataSourceForType(ds.settings.defaultForType, ds); + } + + return ds; +}; + +/** + * Get an in-memory data source. Use one if it already exists. + * + * @param {String} [name] The name of the data source. + * If not provided, the `'default'` is used. + * + * @header loopback.memory() + */ + +registry.memory = function (name) { + name = name || 'default'; + var memory = ( + this._memoryDataSources || (this._memoryDataSources = {}) + )[name]; + + if(!memory) { + memory = this._memoryDataSources[name] = this.createDataSource({ + connector: loopback.Memory + }); + } + + return memory; +}; + +/** + * Set the default `dataSource` for a given `type`. + * @param {String} type The datasource type + * @param {Object|DataSource} dataSource The data source settings or instance + * @returns {DataSource} The data source instance + * + * @header loopback.setDefaultDataSourceForType(type, dataSource) + */ + +registry.setDefaultDataSourceForType = function(type, dataSource) { + var defaultDataSources = this.defaultDataSources || + (this.defaultDataSources = {}); + + if(!(dataSource instanceof DataSource)) { + dataSource = this.createDataSource(dataSource); + } + + defaultDataSources[type] = dataSource; + return dataSource; +}; + +/** + * Get the default `dataSource` for a given `type`. + * @param {String} type The datasource type + * @returns {DataSource} The data source instance + * @header loopback.getDefaultDataSourceForType() + */ + +registry.getDefaultDataSourceForType = function(type) { + return this.defaultDataSources && this.defaultDataSources[type]; +}; + +/** + * Attach any model that does not have a dataSource to + * the default dataSource for the type the Model requests + * @header loopback.autoAttach() + */ + +registry.autoAttach = function() { + var models = this.Model.modelBuilder.models; + assert.equal(typeof models, 'object', 'Cannot autoAttach without a models object'); + + Object.keys(models).forEach(function(modelName) { + var ModelCtor = models[modelName]; + + // Only auto attach if the model doesn't have an explicit data source + if(ModelCtor && (!(ModelCtor.dataSource instanceof DataSource))) { + this.autoAttachModel(ModelCtor); + } + }, this); +}; + +registry.autoAttachModel = function(ModelCtor) { + if(ModelCtor.autoAttach) { + var ds = this.getDefaultDataSourceForType(ModelCtor.autoAttach); + + assert(ds instanceof DataSource, 'cannot autoAttach model "' + + ModelCtor.modelName + + '". No dataSource found of type ' + ModelCtor.autoAttach); + + ModelCtor.attachTo(ds); + } +}; + +/* + * Core models + * @private + */ + +registry.Model = require('./models/model'); +registry.PersistedModel = require('./models/persisted-model'); + +// temporary alias to simplify migration of code based on <=2.0.0-beta3 +Object.defineProperty(registry, 'DataModel', { + get: function() { + var stackLines = new Error().stack.split('\n'); + console.warn('loopback.DataModel is deprecated, ' + + 'use loopback.PersistedModel instead.'); + // Log the location where loopback.DataModel was called + console.warn(stackLines[2]); + return this.PersistedModel; + } +}); + diff --git a/lib/runtime.js b/lib/runtime.js new file mode 100644 index 000000000..f179c5335 --- /dev/null +++ b/lib/runtime.js @@ -0,0 +1,22 @@ +/* + * This is an internal file that should not be used outside of loopback. + * All exported entities can be accessed via the `loopback` object. + * @private + */ + +var runtime = exports; + +/** + * True if running in a browser environment; false otherwise. + * @header loopback.isBrowser + */ + +runtime.isBrowser = typeof window !== 'undefined'; + +/** + * True if running in a server environment; false otherwise. + * @header loopback.isServer + */ + +runtime.isServer = !runtime.isBrowser; + From 1de6325a80a05d3cd9acdb2ca704c8900aa00f8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 6 Jun 2014 14:51:13 +0200 Subject: [PATCH 55/74] test: add debug logs Add debug logs to troubleshoot two unit tests failing on the CI server only. --- test/access-control.integration.js | 5 +++++ test/relations.integration.js | 9 ++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/test/access-control.integration.js b/test/access-control.integration.js index 1abb320e0..0ef59c7b8 100644 --- a/test/access-control.integration.js +++ b/test/access-control.integration.js @@ -6,6 +6,7 @@ var app = require(path.join(ACCESS_CONTROL_APP, 'app.js')); var assert = require('assert'); var USER = {email: 'test@test.test', password: 'test'}; var CURRENT_USER = {email: 'current@test.test', password: 'test'}; +var debug = require('debug')('loopback:test:access-control.integration'); describe('access control - integration', function () { @@ -96,6 +97,10 @@ describe('access control - integration', function () { lt.describe.whenCalledRemotely('GET', '/api/users/:id', function() { lt.it.shouldBeAllowed(); it('should not include a password', function() { + debug('GET /api/users/:id response: %s\nheaders: %j\nbody string: %s', + this.res.statusCode, + this.res.headers, + this.res.text); var user = this.res.body; assert.equal(user.password, undefined); }); diff --git a/test/relations.integration.js b/test/relations.integration.js index 9d9ae16a8..863e28772 100644 --- a/test/relations.integration.js +++ b/test/relations.integration.js @@ -5,6 +5,7 @@ var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-integration-app'); var app = require(path.join(SIMPLE_APP, 'app.js')); var assert = require('assert'); var expect = require('chai').expect; +var debug = require('debug')('loopback:test:relations.integration'); describe('relations - integration', function () { @@ -28,13 +29,19 @@ describe('relations - integration', function () { this.url = '/api/stores/' + this.store.id + '/widgets'; }); lt.describe.whenCalledRemotely('GET', '/api/stores/:id/widgets', function() { + it('should succeed with statusCode 200', function() { assert.equal(this.res.statusCode, 200); }); describe('widgets (response.body)', function() { beforeEach(function() { + debug('GET /api/stores/:id/widgets response: %s' + + '\nheaders: %j\nbody string: %s', + this.res.statusCode, + this.res.headers, + this.res.text); this.widgets = this.res.body; - this.widget = this.res.body[0]; + this.widget = this.res.body && this.res.body[0]; }); it('should be an array', function() { assert(Array.isArray(this.widgets)); From 63843b41fceedb92641eac838b41d86f131f352b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Jun 2014 07:46:32 +0200 Subject: [PATCH 56/74] lib/registry fix jsdoc comments Add missing names. --- lib/registry.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/registry.js b/lib/registry.js index bb905da23..c1d21082a 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -78,7 +78,7 @@ registry.createModel = function (name, properties, options) { * }); * ``` * - * @options {Object} model configuration + * @options {Object} config model configuration * @property {String} name Unique name. * @property {Object} [properties] Model properties * @property {Object} [options] Model options. Options can be specified on the @@ -121,7 +121,7 @@ function buildModelOptionsFromConfig(config) { /** * Alter an existing Model class. * @param {Model} ModelCtor The model constructor to alter. - * @options {Object} Additional configuration to apply + * @options {Object} config Additional configuration to apply * @property {DataSource} dataSource Attach the model to a dataSource. * @property {Object} [relations] Model relations to add/update. * @@ -185,9 +185,10 @@ registry.getModelByType = function(modelType) { * Create a data source with passing the provided options to the connector. * * @param {String} name Optional name. - * @options {Object} Data Source options + * @options {Object} options Data Source options * @property {Object} connector LoopBack connector. - * @property {*} Other properties See the relevant connector documentation. + * @property {*} [*] Other connector properties. + * See the relevant connector documentation. * * @header loopback.createDataSource(name, options) */ From 56aab8dbdd9e662f55e94fd20cda7319615a8f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Jun 2014 11:18:52 +0200 Subject: [PATCH 57/74] Merge createModelFromConfig with createModel Merge the two methods `loopback.createModel` and `loopback.createModelFromConfig` into a single method `createModel`. --- lib/application.js | 2 +- lib/registry.js | 105 +++++++++++++++++++++++++----------------- test/loopback.test.js | 8 ++-- 3 files changed, 68 insertions(+), 47 deletions(-) diff --git a/lib/application.js b/lib/application.js index 88f0f1ffc..1e89f6b08 100644 --- a/lib/application.js +++ b/lib/application.js @@ -141,7 +141,7 @@ app.model = function (Model, config) { // modeller does not understand `dataSource` option delete modelConfig.dataSource; - Model = registry.createModelFromConfig(modelConfig); + Model = registry.createModel(modelConfig); // delete config options already applied ['relations', 'base', 'acls', 'hidden'].forEach(function(prop) { diff --git a/lib/registry.js b/lib/registry.js index c1d21082a..311d0b08c 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -18,6 +18,59 @@ var registry = module.exports; * Create a named vanilla JavaScript class constructor with an attached * set of properties and options. * + * This function comes with two variants: + * * `loopback.createModel(name, properties, options)` + * * `loopback.createModel(config)` + * + * In the second variant, the parameters `name`, `properties` and `options` + * are provided in the config object. Any additional config entries are + * interpreted as `options`, i.e. the following two configs are identical: + * + * ```js + * { name: 'Customer', base: 'User' } + * { name: 'Customer', options: { base: 'User' } } + * ``` + * + * **Example** + * + * Create an `Author` model using the three-parameter variant: + * + * ```js + * loopback.createModel( + * 'Author', + * { + * firstName: 'string', + * lastName: 'string + * }, + * { + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * } + * ); + * ``` + * + * Create the same model using a config object: + * + * ```js + * loopback.createModel({ + * name: 'Author', + * properties: { + * firstName: 'string', + * lastName: 'string + * }, + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * }); + * ``` + * * @param {String} name Unique name. * @param {Object} properties * @param {Object} options (optional) @@ -26,6 +79,16 @@ var registry = module.exports; */ registry.createModel = function (name, properties, options) { + if (arguments.length === 1 && typeof name === 'object') { + var config = name; + name = config.name; + properties = config.properties; + options = buildModelOptionsFromConfig(config); + + assert(typeof name === 'string', + 'The model-config property `name` must be a string'); + } + options = options || {}; var BaseModel = options.base || options.super; @@ -57,48 +120,6 @@ registry.createModel = function (name, properties, options) { return model; }; -/** - * Create a model as described by the configuration object. - * - * **Example** - * - * ```js - * loopback.createModelFromConfig({ - * name: 'Author', - * properties: { - * firstName: 'string', - * lastName: 'string - * }, - * relations: { - * books: { - * model: 'Book', - * type: 'hasAndBelongsToMany' - * } - * } - * }); - * ``` - * - * @options {Object} config model configuration - * @property {String} name Unique name. - * @property {Object} [properties] Model properties - * @property {Object} [options] Model options. Options can be specified on the - * top level config object too. E.g. `{ base: 'User' }` is the same as - * `{ options: { base: 'User' } }`. - * - * @header loopback.createModelFromConfig(config) - */ - -registry.createModelFromConfig = function(config) { - var name = config.name; - var properties = config.properties; - var options = buildModelOptionsFromConfig(config); - - assert(typeof name === 'string', - 'The model-config property `name` must be a string'); - - return this.createModel(name, properties, options); -}; - function buildModelOptionsFromConfig(config) { var options = extend({}, config.options); for (var key in config) { diff --git a/test/loopback.test.js b/test/loopback.test.js index 5cb280989..bb0ef95a5 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -127,9 +127,9 @@ describe('loopback', function() { }); }); - describe('loopback.createModelFromConfig(config)', function() { + describe('loopback.createModel(config)', function() { it('creates the model', function() { - var model = loopback.createModelFromConfig({ + var model = loopback.createModel({ name: uniqueModelName }); @@ -137,7 +137,7 @@ describe('loopback', function() { }); it('interprets extra first-level keys as options', function() { - var model = loopback.createModelFromConfig({ + var model = loopback.createModel({ name: uniqueModelName, base: 'User' }); @@ -146,7 +146,7 @@ describe('loopback', function() { }); it('prefers config.options.key over config.key', function() { - var model = loopback.createModelFromConfig({ + var model = loopback.createModel({ name: uniqueModelName, base: 'User', options: { From 09cc57c0613617f43e93c657a96bca589ef00ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Jun 2014 16:15:56 +0200 Subject: [PATCH 58/74] registry: fix non-unique default dataSources Fix the problem where `registry.defaultDataSources` has two instances: - `require('loopback').defaultDataSources` used by `loopback.autoAttach()` - `require('./registry').defaultDataSources` used by `app.dataSource`. I am intentionally leaving out unit-tests as the whole `autoAttach` feature is going to be deleted before 2.0 is released. --- lib/registry.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/registry.js b/lib/registry.js index 311d0b08c..0945af7db 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -13,6 +13,7 @@ var DataSource = require('loopback-datasource-juggler').DataSource; var registry = module.exports; +registry.defaultDataSources = {}; /** * Create a named vanilla JavaScript class constructor with an attached @@ -264,8 +265,7 @@ registry.memory = function (name) { */ registry.setDefaultDataSourceForType = function(type, dataSource) { - var defaultDataSources = this.defaultDataSources || - (this.defaultDataSources = {}); + var defaultDataSources = this.defaultDataSources; if(!(dataSource instanceof DataSource)) { dataSource = this.createDataSource(dataSource); From a90e24c013570c1d78047a53f6f3ca02f6c5b032 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 9 Jun 2014 16:25:35 +0200 Subject: [PATCH 59/74] registry: export DataSource class Expose the juggler's DataSource constructor as `loopback.DataSource`. The DataSource constructor is most useful to check for `instanceof DataSource`, but it also makes the loopback API more consistent, since the API is already exposing all pre-built Models. --- lib/registry.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/registry.js b/lib/registry.js index 0945af7db..b3666c0bc 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -318,6 +318,8 @@ registry.autoAttachModel = function(ModelCtor) { } }; +registry.DataSource = DataSource; + /* * Core models * @private From b576639d90a7cf73f04d2257eef757b2af8d34da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 10 Jun 2014 11:19:36 +0200 Subject: [PATCH 60/74] Add loopback.version back Add `loopback.version` that was accidentaly removed by #308. --- lib/loopback.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/loopback.js b/lib/loopback.js index 12d46314b..893f37d20 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -26,6 +26,12 @@ var express = require('express') var loopback = exports = module.exports = createApplication; +/** + * Framework version. + */ + +loopback.version = require('../package.json').version; + /** * Expose mime. */ From f80259a2453a78bd39523dfda1384fe66871ec1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 13 Jun 2014 10:19:15 +0200 Subject: [PATCH 61/74] Remove loopback-explorer from dev deps 1. loopback-explorer has a peer dependency on loopback, which forces `npm install` to install loopback within loopback. Somehow npm ends up installing loopback 1.x, which is not compatible with datasource-juggler 2.x. 2. The only place using loopback-explorer is the app used by e2e tests. However, the e2e test app does not load the explorer at the moment and the e2e test are not being run anyway. --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 8132da28e..53668356e 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,7 @@ "karma": "~0.12.16", "karma-browserify": "~0.2.1", "karma-mocha": "~0.1.3", - "grunt-karma": "~0.8.3", - "loopback-explorer": "~1.1.1" + "grunt-karma": "~0.8.3" }, "repository": { "type": "git", From 7316048afc1164ed91a004a8f4f58f3603df456f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 16 Jun 2014 16:13:24 +0200 Subject: [PATCH 62/74] lib/registry: `getModel` throws, add `findModel` Rename `loopback.getModel` to `loopback.findModel`. Implement `loopback.getModel` as a wrapper around `findModel` that throws an error when the model as not found. --- lib/models/acl.js | 4 ++-- lib/models/change.js | 2 +- lib/registry.js | 18 +++++++++++++++++- test/loopback.test.js | 7 ++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/models/acl.js b/lib/models/acl.js index a3126b873..2c0abe579 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -261,7 +261,7 @@ ACL.resolvePermission = function resolvePermission(acls, req) { * @return {Object[]} An array of ACLs */ ACL.getStaticACLs = function getStaticACLs(model, property) { - var modelClass = loopback.getModel(model); + var modelClass = loopback.findModel(model); var staticACLs = []; if (modelClass && modelClass.settings.acls) { modelClass.settings.acls.forEach(function (acl) { @@ -343,7 +343,7 @@ ACL.checkPermission = function checkPermission(principalType, principalId, acls = acls.concat(dynACLs); resolved = self.resolvePermission(acls, req); if(resolved && resolved.permission === ACL.DEFAULT) { - var modelClass = loopback.getModel(model); + var modelClass = loopback.findModel(model); resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; } callback && callback(null, resolved); diff --git a/lib/models/change.js b/lib/models/change.js index 654207259..e66a84538 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -128,7 +128,7 @@ Change.idForModel = function(modelName, modelId) { */ Change.findOrCreateChange = function(modelName, modelId, callback) { - assert(loopback.getModel(modelName), modelName + ' does not exist'); + assert(loopback.findModel(modelName), modelName + ' does not exist'); var id = this.idForModel(modelName, modelId); var Change = this; diff --git a/lib/registry.js b/lib/registry.js index b3666c0bc..59c3d94d5 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -176,10 +176,26 @@ registry.configureModel = function(ModelCtor, config) { * @param {String} modelName The model name * @returns {Model} The model class * + * @header loopback.findModel(modelName) + */ +registry.findModel = function(modelName) { + return this.Model.modelBuilder.models[modelName]; +}; + +/** + * Look up a model class by name from all models created by + * `loopback.createModel()`. Throw an error when no such model exists. + * + * @param {String} modelName The model name + * @returns {Model} The model class + * * @header loopback.getModel(modelName) */ registry.getModel = function(modelName) { - return this.Model.modelBuilder.models[modelName]; + var model = this.findModel(modelName); + if (model) return model; + + throw new Error('Model not found: ' + modelName); }; /** diff --git a/test/loopback.test.js b/test/loopback.test.js index bb0ef95a5..c4dcf313e 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -107,7 +107,7 @@ describe('loopback', function() { }); assert(loopback.getModel('MyModel') === MyModel); assert(loopback.getModel('MyCustomModel') === MyCustomModel); - assert(loopback.getModel('Invalid') === undefined); + assert(loopback.findModel('Invalid') === undefined); }); it('should be able to get model by type', function () { var MyModel = loopback.createModel('MyModel', {}, { @@ -124,6 +124,11 @@ describe('loopback', function() { assert(loopback.getModelByType(MyModel) === MyCustomModel); assert(loopback.getModelByType(MyCustomModel) === MyCustomModel); }); + + it('should throw when the model does not exist', function() { + expect(function() { loopback.getModel(uniqueModelName); }) + .to.throw(Error, new RegExp('Model not found: ' + uniqueModelName)); + }); }); }); From 5eca225c0ac94cb063f34e0d9733f0147da2543b Mon Sep 17 00:00:00 2001 From: Ritchie Martori Date: Thu, 19 Jun 2014 12:15:46 -0700 Subject: [PATCH 63/74] Fix remote method definition in client-server example --- example/client-server/models.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/example/client-server/models.js b/example/client-server/models.js index f6dc594b7..34d5c8bac 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -22,8 +22,7 @@ CartItem.sum = function(cartId, callback) { }); } -loopback.remoteMethod( - CartItem.sum, +CartItem.remoteMethod('sum', { accepts: {arg: 'cartId', type: 'number'}, returns: {arg: 'total', type: 'number'} From c896c78db0db66549c4ee2f1f10893b2a625864f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 25 Jun 2014 13:52:20 +0200 Subject: [PATCH 64/74] Remove `app.boot` Modify `app.boot` to throw an exception, pointing users to the new module `loopback-boot`. --- lib/application.js | 284 +------------------- package.json | 1 + test/app.test.js | 194 +------------ test/fixtures/access-control/app.js | 3 +- test/fixtures/simple-integration-app/app.js | 3 +- 5 files changed, 9 insertions(+), 476 deletions(-) diff --git a/lib/application.js b/lib/application.js index eb01d398e..c56066e08 100644 --- a/lib/application.js +++ b/lib/application.js @@ -372,210 +372,9 @@ app.enableAuth = function() { this.isAuthEnabled = true; }; -/** - * Initialize an application from an options object or a set of JSON and JavaScript files. - * - * **Deprecated. Use the package - * [loopback-boot](https://github.com/strongloop/loopback-boot) instead.** - * - * This function takes an optional argument that is either a string or an object. - * - * If the argument is a string, then it sets the application root directory based on the string value. Then it: - * 1. Creates DataSources from the `datasources.json` file in the application root directory. - * 2. Creates Models from the `models.json` file in the application root directory. - * - * If the argument is an object, then it looks for `model`, `dataSources`, and `appRootDir` properties of the object. - * If the object has no `appRootDir` property then it sets the current working directory as the application root directory. - * Then it: - * 1. Creates DataSources from the `options.dataSources` object. - * 2. Creates Models from the `options.models` object. - * - * In both cases, the function loads JavaScript files in the `/models` and `/boot` subdirectories of the application root directory with `require()`. - * - * **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple - * files may result in models being **undefined** due to race conditions. - * To avoid this when using `app.boot()` make sure all models are passed as part of the `models` definition. - * - * Throws an error if the config object is not valid or if boot fails. - * - * - * **Model Definitions** - * - * The following is example JSON for two `Model` definitions: "dealership" and "location". - * - * ```js - * { - * "dealership": { - * // a reference, by name, to a dataSource definition - * "dataSource": "my-db", - * // the options passed to Model.extend(name, properties, options) - * "options": { - * "relations": { - * "cars": { - * "type": "hasMany", - * "model": "Car", - * "foreignKey": "dealerId" - * } - * } - * }, - * // the properties passed to Model.extend(name, properties, options) - * "properties": { - * "id": {"id": true}, - * "name": "String", - * "zip": "Number", - * "address": "String" - * } - * }, - * "car": { - * "dataSource": "my-db" - * "properties": { - * "id": { - * "type": "String", - * "required": true, - * "id": true - * }, - * "make": { - * "type": "String", - * "required": true - * }, - * "model": { - * "type": "String", - * "required": true - * } - * } - * } - * } - * ``` - * @options {String|Object} options Boot options; If String, this is the application root directory; if object, has below properties. - * @property {String} appRootDir Directory to use when loading JSON and JavaScript files (optional). Defaults to the current directory (`process.cwd()`). - * @property {Object} models Object containing `Model` definitions (optional). - * @property {Object} dataSources Object containing `DataSource` definitions (optional). - * @end - * - * @header app.boot([options]) - */ - app.boot = function(options) { - options = options || {}; - - if(typeof options === 'string') { - options = {appRootDir: options}; - } - var app = this; - var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); - var ctx = {}; - var appConfig = options.app; - var modelConfig = options.models; - var dataSourceConfig = options.dataSources; - - if(!appConfig) { - appConfig = tryReadConfig(appRootDir, 'app') || {}; - } - if(!modelConfig) { - modelConfig = tryReadConfig(appRootDir, 'models') || {}; - } - if(!dataSourceConfig) { - dataSourceConfig = tryReadConfig(appRootDir, 'datasources') || {}; - } - - assertIsValidConfig('app', appConfig); - assertIsValidConfig('model', modelConfig); - assertIsValidConfig('data source', dataSourceConfig); - - appConfig.host = - process.env.npm_config_host || - process.env.OPENSHIFT_SLS_IP || - process.env.OPENSHIFT_NODEJS_IP || - process.env.HOST || - appConfig.host || - process.env.npm_package_config_host || - app.get('host'); - - appConfig.port = _.find([ - process.env.npm_config_port, - process.env.OPENSHIFT_SLS_PORT, - process.env.OPENSHIFT_NODEJS_PORT, - process.env.PORT, - appConfig.port, - process.env.npm_package_config_port, - app.get('port'), - 3000 - ], _.isFinite); - - appConfig.restApiRoot = - appConfig.restApiRoot || - app.get('restApiRoot') || - '/api'; - - if(appConfig.host !== undefined) { - assert(typeof appConfig.host === 'string', 'app.host must be a string'); - app.set('host', appConfig.host); - } - - if(appConfig.port !== undefined) { - var portType = typeof appConfig.port; - assert(portType === 'string' || portType === 'number', 'app.port must be a string or number'); - app.set('port', appConfig.port); - } - - assert(appConfig.restApiRoot !== undefined, 'app.restApiRoot is required'); - assert(typeof appConfig.restApiRoot === 'string', 'app.restApiRoot must be a string'); - assert(/^\//.test(appConfig.restApiRoot), 'app.restApiRoot must start with "/"'); - app.set('restApiRoot', appConfig.restApiRoot); - - for(var configKey in appConfig) { - var cur = app.get(configKey); - if(cur === undefined || cur === null) { - app.set(configKey, appConfig[configKey]); - } - } - - // instantiate data sources - forEachKeyedObject(dataSourceConfig, function(key, obj) { - app.dataSource(key, obj); - }); - - // instantiate models - forEachKeyedObject(modelConfig, function(key, obj) { - app.model(key, obj); - }); - - // try to attach models to dataSources by type - try { - registry.autoAttach(); - } catch(e) { - if(e.name === 'AssertionError') { - console.warn(e); - } else { - throw e; - } - } - - // disable token requirement for swagger, if available - var swagger = app.remotes().exports.swagger; - var requireTokenForSwagger = appConfig.swagger - && appConfig.swagger.requireToken; - if(swagger) { - swagger.requireToken = requireTokenForSwagger || false; - } - - // require directories - var requiredModels = requireDir(path.join(appRootDir, 'models')); - var requiredBootScripts = requireDir(path.join(appRootDir, 'boot')); -} - -function assertIsValidConfig(name, config) { - if(config) { - assert(typeof config === 'object', name + ' config must be a valid JSON object'); - } -} - -function forEachKeyedObject(obj, fn) { - if(typeof obj !== 'object') return; - - Object.keys(obj).forEach(function(key) { - fn(key, obj[key]); - }); + throw new Error( + '`app.boot` was removed, use the new module loopback-boot instead'); } function classify(str) { @@ -628,85 +427,6 @@ function configureModel(ModelCtor, config, app) { registry.configureModel(ModelCtor, config); } -function requireDir(dir, basenames) { - assert(dir, 'cannot require directory contents without directory name'); - - var requires = {}; - - if (arguments.length === 2) { - // if basenames argument is passed, explicitly include those files - basenames.forEach(function (basename) { - var filepath = Path.resolve(Path.join(dir, basename)); - requires[basename] = tryRequire(filepath); - }); - } else if (arguments.length === 1) { - // if basenames arguments isn't passed, require all javascript - // files (except for those prefixed with _) and all directories - - var files = tryReadDir(dir); - - // sort files in lowercase alpha for linux - files.sort(function (a,b) { - a = a.toLowerCase(); - b = b.toLowerCase(); - - if (a < b) { - return -1; - } else if (b < a) { - return 1; - } else { - return 0; - } - }); - - files.forEach(function (filename) { - // ignore index.js and files prefixed with underscore - if ((filename === 'index.js') || (filename[0] === '_')) { return; } - - var filepath = path.resolve(path.join(dir, filename)); - var ext = path.extname(filename); - var stats = fs.statSync(filepath); - - // only require files supported by require.extensions (.txt .md etc.) - if (stats.isFile() && !(ext in require.extensions)) { return; } - - var basename = path.basename(filename, ext); - - requires[basename] = tryRequire(filepath); - }); - - } - - return requires; -}; - -function tryRequire(modulePath) { - try { - return require.apply(this, arguments); - } catch(e) { - console.error('failed to require "%s"', modulePath); - throw e; - } -} - -function tryReadDir() { - try { - return fs.readdirSync.apply(fs, arguments); - } catch(e) { - return []; - } -} - -function tryReadConfig(cwd, fileName) { - try { - return require(path.join(cwd, fileName + '.json')); - } catch(e) { - if(e.code !== "MODULE_NOT_FOUND") { - throw e; - } - } -} - function clearHandlerCache(app) { app._handlers = undefined; } diff --git a/package.json b/package.json index 53668356e..bef9fb8d9 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "strong-task-emitter": "0.0.x", "supertest": "~0.13.0", "chai": "~1.9.1", + "loopback-boot": "1.x >=1.1", "loopback-testing": "~0.2.0", "browserify": "~4.1.6", "grunt": "~0.4.5", diff --git a/test/app.test.js b/test/app.test.js index e3fed516d..de001b509 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -86,15 +86,8 @@ describe('app', function() { beforeEach(function() { app = loopback(); - app.boot({ - app: {port: 3000, host: '127.0.0.1'}, - // prevent loading of models.json, it is not available in the browser - models: {}, - dataSources: { - db: { - connector: 'memory' - } - } + app.dataSource('db', { + connector: 'memory' }); }); @@ -190,189 +183,6 @@ describe('app', function() { }); }); - describe.onServer('app.boot([options])', function () { - beforeEach(function () { - app.boot({ - app: { - port: 3000, - host: '127.0.0.1', - restApiRoot: '/rest-api', - foo: {bar: 'bat'}, - baz: true - }, - models: { - 'foo-bar-bat-baz': { - options: { - plural: 'foo-bar-bat-bazzies' - }, - dataSource: 'the-db' - } - }, - dataSources: { - 'the-db': { - connector: 'memory' - } - } - }); - }); - - it('should have port setting', function () { - assert.equal(this.app.get('port'), 3000); - }); - - it('should have host setting', function() { - assert.equal(this.app.get('host'), '127.0.0.1'); - }); - - it('should have restApiRoot setting', function() { - assert.equal(this.app.get('restApiRoot'), '/rest-api'); - }); - - it('should have other settings', function () { - expect(this.app.get('foo')).to.eql({ - bar: 'bat' - }); - expect(this.app.get('baz')).to.eql(true); - }); - - describe('boot and models directories', function() { - beforeEach(function() { - var app = this.app = loopback(); - app.boot(SIMPLE_APP); - }); - - it('should run all modules in the boot directory', function () { - assert(process.loadedFooJS); - delete process.loadedFooJS; - }); - - it('should run all modules in the models directory', function () { - assert(process.loadedBarJS); - delete process.loadedBarJS; - }); - }); - - describe('PaaS and npm env variables', function() { - beforeEach(function() { - this.boot = function () { - var app = loopback(); - app.boot({ - app: { - port: undefined, - host: undefined - } - }); - return app; - } - }); - - it('should be honored', function() { - var assertHonored = function (portKey, hostKey) { - process.env[hostKey] = randomPort(); - process.env[portKey] = randomHost(); - var app = this.boot(); - assert.equal(app.get('port'), process.env[portKey]); - assert.equal(app.get('host'), process.env[hostKey]); - delete process.env[portKey]; - delete process.env[hostKey]; - }.bind(this); - - assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_NODEJS_IP'); - assertHonored('npm_config_port', 'npm_config_host'); - assertHonored('npm_package_config_port', 'npm_package_config_host'); - assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_SLS_IP'); - assertHonored('PORT', 'HOST'); - }); - - it('should be honored in order', function() { - process.env.npm_config_host = randomHost(); - process.env.OPENSHIFT_SLS_IP = randomHost(); - process.env.OPENSHIFT_NODEJS_IP = randomHost(); - process.env.HOST = randomHost(); - process.env.npm_package_config_host = randomHost(); - - var app = this.boot(); - assert.equal(app.get('host'), process.env.npm_config_host); - - delete process.env.npm_config_host; - delete process.env.OPENSHIFT_SLS_IP; - delete process.env.OPENSHIFT_NODEJS_IP; - delete process.env.HOST; - delete process.env.npm_package_config_host; - - process.env.npm_config_port = randomPort(); - process.env.OPENSHIFT_SLS_PORT = randomPort(); - process.env.OPENSHIFT_NODEJS_PORT = randomPort(); - process.env.PORT = randomPort(); - process.env.npm_package_config_port = randomPort(); - - var app = this.boot(); - assert.equal(app.get('host'), process.env.npm_config_host); - assert.equal(app.get('port'), process.env.npm_config_port); - - delete process.env.npm_config_port; - delete process.env.OPENSHIFT_SLS_PORT; - delete process.env.OPENSHIFT_NODEJS_PORT; - delete process.env.PORT; - delete process.env.npm_package_config_port; - }); - - function randomHost() { - return Math.random().toString().split('.')[1]; - } - - function randomPort() { - return Math.floor(Math.random() * 10000); - } - - it('should honor 0 for free port', function () { - var app = loopback(); - app.boot({app: {port: 0}}); - assert.equal(app.get('port'), 0); - }); - - it('should default to port 3000', function () { - var app = loopback(); - app.boot({app: {port: undefined}}); - assert.equal(app.get('port'), 3000); - }); - }); - - it('Instantiate models', function () { - assert(app.models); - assert(app.models.FooBarBatBaz); - assert(app.models.fooBarBatBaz); - assertValidDataSource(app.models.FooBarBatBaz.dataSource); - assert.isFunc(app.models.FooBarBatBaz, 'find'); - assert.isFunc(app.models.FooBarBatBaz, 'create'); - }); - - it('Attach models to data sources', function () { - assert.equal(app.models.FooBarBatBaz.dataSource, app.dataSources.theDb); - }); - - it('Instantiate data sources', function () { - assert(app.dataSources); - assert(app.dataSources.theDb); - assertValidDataSource(app.dataSources.theDb); - assert(app.dataSources.TheDb); - }); - }); - - describe.onServer('app.boot(appRootDir)', function () { - it('Load config files', function () { - var app = loopback(); - - app.boot(SIMPLE_APP); - - assert(app.models.foo); - assert(app.models.Foo); - assert(app.models.Foo.dataSource); - assert.isFunc(app.models.Foo, 'find'); - assert.isFunc(app.models.Foo, 'create'); - }); - }); - describe.onServer('listen()', function() { it('starts http server', function(done) { var app = loopback(); diff --git a/test/fixtures/access-control/app.js b/test/fixtures/access-control/app.js index fb01c3dac..a8736a445 100644 --- a/test/fixtures/access-control/app.js +++ b/test/fixtures/access-control/app.js @@ -1,8 +1,9 @@ var loopback = require('../../../'); +var boot = require('loopback-boot'); var path = require('path'); var app = module.exports = loopback(); -app.boot(__dirname); +boot(app, __dirname); var apiPath = '/api'; app.use(loopback.cookieParser('secret')); diff --git a/test/fixtures/simple-integration-app/app.js b/test/fixtures/simple-integration-app/app.js index 5f71b1d3f..a4cbb2a44 100644 --- a/test/fixtures/simple-integration-app/app.js +++ b/test/fixtures/simple-integration-app/app.js @@ -1,8 +1,9 @@ var loopback = require('../../../'); +var boot = require('loopback-boot'); var path = require('path'); var app = module.exports = loopback(); -app.boot(__dirname); +boot(app, __dirname); app.use(loopback.favicon()); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; From 41b826d3c5a0632766fbddc2886e9895fb575a96 Mon Sep 17 00:00:00 2001 From: Laurent Date: Wed, 25 Jun 2014 14:17:57 +0200 Subject: [PATCH 65/74] Allow peer to use beta2 of datasource-juggler (and future) Signed-off-by: Laurent Chenay --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 53668356e..a4bec58ae 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "debug": "~0.8.1", "express": "4.x", "body-parser": "~1.2.2", - "strong-remoting": "2.0.0-beta4", + "strong-remoting": "~2.0.0-beta4", "inflection": "~1.3.5", "nodemailer": "~0.6.5", "ejs": "~1.0.0", @@ -46,13 +46,13 @@ "canonical-json": "0.0.4" }, "peerDependencies": { - "loopback-datasource-juggler": "2.0.0-beta1" + "loopback-datasource-juggler": "~2.0.0-beta1" }, "devDependencies": { "cookie-parser": "~1.1.0", "errorhandler": "~1.0.1", "serve-favicon": "~2.0.0", - "loopback-datasource-juggler": "2.0.0-beta1", + "loopback-datasource-juggler": "~2.0.0-beta1", "mocha": "~1.20.1", "strong-task-emitter": "0.0.x", "supertest": "~0.13.0", From 50816ebbe371589db93d1a08e68a218307b31153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 23 Jun 2014 14:44:19 +0200 Subject: [PATCH 66/74] Fix loopback in PhantomJS, fix karma tests - Move configuration of Karma unit-tests from `Gruntfile.js` to a standalone file (`test/karma.conf.js`). - Add a new Grunt task `karma:unit-ci` to run Karma unit-tests in PhantomJS and produce karma-xunit.xml file that can be consumed by the CI server. - Add grunt-mocha-test, configure it to run unit-tests. - Add `grunt test` task that runs both karma and mocha tests, detects Jenkins to produce XML output on CI server. - Modify the `test` script in `package.json` to run `grunt mocha-and-karma` (an alias for `grunt test`). The alias is required to trick `sl-ci-run` to run `npm test` instead of calling directly `mocha`. - Add `es5-shim` module to karma unit-tests in order to provide ES5-methods required by LoopBack. - Fix `mixin(source)` in lib/loopback.js to work in PhantomJS. `Object.getOwnPropertyDescriptor()` provided by `es5-shim` does not work in the same way as in Node. --- .gitignore | 1 + Gruntfile.js | 116 +++++++++++++++------------------------------ lib/loopback.js | 4 ++ package.json | 36 +++++++------- test/karma.conf.js | 98 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 161 insertions(+), 94 deletions(-) create mode 100644 test/karma.conf.js diff --git a/.gitignore b/.gitignore index fdcae0c86..a30033bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ *.swo node_modules dist +*xunit.xml diff --git a/Gruntfile.js b/Gruntfile.js index 205feff8f..09aa93fe4 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,6 +1,8 @@ /*global module:false*/ module.exports = function(grunt) { + grunt.loadNpmTasks('grunt-mocha-test'); + // Project configuration. grunt.initConfig({ // Metadata. @@ -53,87 +55,39 @@ module.exports = function(grunt) { } } }, - karma: { - unit: { + mochaTest: { + 'unit': { + src: 'test/*.js', options: { - // base path, that will be used to resolve files and exclude - basePath: '', - - // frameworks to use - frameworks: ['mocha', 'browserify'], - - // list of files / patterns to load in the browser - files: [ - 'test/support.js', - 'test/model.test.js', - 'test/geo-point.test.js', - 'test/app.test.js' - ], - - // list of files to exclude - exclude: [ - - ], - - // test results reporter to use - // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' - reporters: ['dots'], - - // web server port - port: 9876, - - // cli runner port - runnerPort: 9100, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: 'warn', - - // enable / disable watching file and executing tests whenever any file changes - autoWatch: true, - - // Start these browsers, currently available: - // - Chrome - // - ChromeCanary - // - Firefox - // - Opera - // - Safari (only Mac) - // - PhantomJS - // - IE (only Windows) - browsers: [ - 'Chrome' - ], - - // If browser does not capture in given timeout [ms], kill it - captureTimeout: 60000, - - // Continuous Integration mode - // if true, it capture browsers, run tests and exit - singleRun: false, - - // Browserify config (all optional) - browserify: { - // extensions: ['.coffee'], - ignore: [ - 'nodemailer', - 'passport', - 'passport-local', - 'superagent', - 'supertest' - ], - // transform: ['coffeeify'], - // debug: true, - // noParse: ['jquery'], - watch: true, - }, - - // Add browserify to preprocessors - preprocessors: {'test/*': ['browserify']} + reporter: 'dot', } }, + 'unit-xml': { + src: 'test/*.js', + options: { + reporter: 'xunit', + captureFile: 'xunit.xml' + } + } + }, + karma: { + 'unit-once': { + configFile: 'test/karma.conf.js', + browsers: [ 'PhantomJS' ], + singleRun: true, + reporters: ['dots', 'junit'], + + // increase the timeout for slow build slaves (e.g. Travis-ci) + browserNoActivityTimeout: 30000, + + // CI friendly test output + junitReporter: { + outputFile: 'karma-xunit.xml' + }, + }, + unit: { + configFile: 'test/karma.conf.js', + }, e2e: { options: { // base path, that will be used to resolve files and exclude @@ -234,4 +188,10 @@ module.exports = function(grunt) { // Default task. grunt.registerTask('default', ['browserify']); + grunt.registerTask('test', [ + process.env.JENKINS_HOME ? 'mochaTest:unit-xml' : 'mochaTest:unit', + 'karma:unit-once']); + + // alias for sl-ci-run and `npm test` + grunt.registerTask('mocha-and-karma', ['test']); }; diff --git a/lib/loopback.js b/lib/loopback.js index 0f9f72f71..40eca19ce 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -80,6 +80,10 @@ function createApplication() { function mixin(source) { for (var key in source) { var desc = Object.getOwnPropertyDescriptor(source, key); + + // Fix for legacy (pre-ES5) browsers like PhantomJS + if (!desc) continue; + Object.defineProperty(loopback, key, desc); } } diff --git a/package.json b/package.json index 0c7f820c3..7cec28374 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ ], "version": "2.0.0-beta3", "scripts": { - "test": "mocha -R spec" + "test": "grunt mocha-and-karma" }, "dependencies": { "debug": "~0.8.1", @@ -49,31 +49,35 @@ "loopback-datasource-juggler": "~2.0.0-beta1" }, "devDependencies": { + "browserify": "~4.1.6", + "chai": "~1.9.1", "cookie-parser": "~1.1.0", "errorhandler": "~1.0.1", - "serve-favicon": "~2.0.0", - "loopback-datasource-juggler": "~2.0.0-beta1", - "mocha": "~1.20.1", - "strong-task-emitter": "0.0.x", - "supertest": "~0.13.0", - "chai": "~1.9.1", - "loopback-boot": "1.x >=1.1", - "loopback-testing": "~0.2.0", - "browserify": "~4.1.6", + "es5-shim": "^3.4.0", "grunt": "~0.4.5", "grunt-browserify": "~2.1.0", - "grunt-contrib-uglify": "~0.4.0", + "grunt-cli": "^0.1.13", "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-uglify": "~0.4.0", "grunt-contrib-watch": "~0.6.1", - "karma-script-launcher": "~0.1.0", + "grunt-karma": "~0.8.3", + "grunt-mocha-test": "^0.11.0", + "karma": "~0.12.16", + "karma-browserify": "~0.2.1", "karma-chrome-launcher": "~0.1.4", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", + "karma-junit-reporter": "^0.2.2", + "karma-mocha": "^0.1.4", "karma-phantomjs-launcher": "~0.1.4", - "karma": "~0.12.16", - "karma-browserify": "~0.2.1", - "karma-mocha": "~0.1.3", - "grunt-karma": "~0.8.3" + "karma-script-launcher": "~0.1.0", + "loopback-boot": "1.x >=1.1", + "loopback-datasource-juggler": "~2.0.0-beta1", + "loopback-testing": "~0.2.0", + "mocha": "~1.20.1", + "serve-favicon": "~2.0.0", + "strong-task-emitter": "0.0.x", + "supertest": "~0.13.0" }, "repository": { "type": "git", diff --git a/test/karma.conf.js b/test/karma.conf.js new file mode 100644 index 000000000..6b7129041 --- /dev/null +++ b/test/karma.conf.js @@ -0,0 +1,98 @@ +// Karma configuration +// http://karma-runner.github.io/0.12/config/configuration-file.html + +module.exports = function(config) { + config.set({ + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // base path, that will be used to resolve files and exclude + basePath: '../', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['mocha', 'browserify'], + + // list of files / patterns to load in the browser + files: [ + 'node_modules/es5-shim/es5-shim.js', + 'test/support.js', + 'test/model.test.js', + 'test/geo-point.test.js', + 'test/app.test.js' + ], + + // list of files / patterns to exclude + exclude: [ + ], + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['dots'], + + // web server port + port: 9876, + + // cli runner port + runnerPort: 9100, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: [ + 'Chrome' + ], + + // Which plugins to enable + plugins: [ + 'karma-browserify', + 'karma-mocha', + 'karma-phantomjs-launcher', + 'karma-chrome-launcher', + 'karma-junit-reporter' + ], + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false, + + colors: true, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + // Uncomment the following lines if you are using grunt's server to run the tests + // proxies: { + // '/': 'http://localhost:9000/' + // }, + // URL root prevent conflicts with the site root + // urlRoot: '_karma_' + + // Browserify config (all optional) + browserify: { + // extensions: ['.coffee'], + ignore: [ + 'nodemailer', + 'passport', + 'passport-local', + 'superagent', + 'supertest' + ], + // transform: ['coffeeify'], + debug: true, + // noParse: ['jquery'], + watch: true, + }, + + // Add browserify to preprocessors + preprocessors: {'test/*': ['browserify']} + }); +}; From fb66bf5c448dd2ab8ea61d6a86102dfceeed4de3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 24 Jun 2014 08:31:31 +0200 Subject: [PATCH 67/74] package: upgrade juggler to 2.0.0-beta2 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 7cec28374..d6625d3ec 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "canonical-json": "0.0.4" }, "peerDependencies": { - "loopback-datasource-juggler": "~2.0.0-beta1" + "loopback-datasource-juggler": "~2.0.0-beta2" }, "devDependencies": { "browserify": "~4.1.6", @@ -72,7 +72,7 @@ "karma-phantomjs-launcher": "~0.1.4", "karma-script-launcher": "~0.1.0", "loopback-boot": "1.x >=1.1", - "loopback-datasource-juggler": "~2.0.0-beta1", + "loopback-datasource-juggler": "~2.0.0-beta2", "loopback-testing": "~0.2.0", "mocha": "~1.20.1", "serve-favicon": "~2.0.0", From f92d18939ad52f853eb51c4487197c31b03cfb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 26 Jun 2014 14:58:58 +0200 Subject: [PATCH 68/74] 2.0.0-beta4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d6625d3ec..e94b74658 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "mobile", "mBaaS" ], - "version": "2.0.0-beta3", + "version": "2.0.0-beta4", "scripts": { "test": "grunt mocha-and-karma" }, From be48a504e09a941b58b9cff45e3bb9ee8367dd73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 3 Jul 2014 10:14:41 +0200 Subject: [PATCH 69/74] 2.0.0-beta5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33dcb0d39..64558b92e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "mobile", "mBaaS" ], - "version": "2.0.0-beta4", + "version": "2.0.0-beta5", "scripts": { "test": "grunt mocha-and-karma" }, From 31ef6394b441ab53052b1f8d2f933db5b227a9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 11 Jul 2014 17:07:22 +0200 Subject: [PATCH 70/74] checkpoint: fix `current()` Fix the query in `Checkpoint.current()` to correctly specify sorting `seq DESC`. Before this change, the first checkpoint was returned as the current one. --- lib/models/checkpoint.js | 2 +- test/checkpoint.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 test/checkpoint.test.js diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js index 704296c2d..2d607bf06 100644 --- a/lib/models/checkpoint.js +++ b/lib/models/checkpoint.js @@ -48,7 +48,7 @@ Checkpoint.current = function(cb) { var Checkpoint = this; this.find({ limit: 1, - sort: 'seq DESC' + order: 'seq DESC' }, function(err, checkpoints) { if(err) return cb(err); var checkpoint = checkpoints[0]; diff --git a/test/checkpoint.test.js b/test/checkpoint.test.js new file mode 100644 index 000000000..53a9a9888 --- /dev/null +++ b/test/checkpoint.test.js @@ -0,0 +1,24 @@ +var async = require('async'); +var loopback = require('../'); + +// create a unique Checkpoint model +var Checkpoint = require('../lib/models/checkpoint').extend('TestCheckpoint'); +Checkpoint.attachTo(loopback.memory()); + +describe('Checkpoint', function() { + describe('current()', function() { + it('returns the highest `seq` value', function(done) { + async.series([ + Checkpoint.create.bind(Checkpoint), + Checkpoint.create.bind(Checkpoint), + function(next) { + Checkpoint.current(function(err, seq) { + if (err) next(err); + expect(seq).to.equal(2); + next(); + }); + } + ], done); + }); + }); +}); From 087c602669740f968c544410f669065b556c4c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jul 2014 09:14:54 +0200 Subject: [PATCH 71/74] models/change: fix typo --- lib/models/change.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/change.js b/lib/models/change.js index e66a84538..9f291fdde 100644 --- a/lib/models/change.js +++ b/lib/models/change.js @@ -609,7 +609,7 @@ Conflict.prototype.changes = function(cb) { Conflict.prototype.resolve = function(cb) { var conflict = this; conflict.changes(function(err, sourceChange, targetChange) { - if(err) return callback(err); + if(err) return cb(err); sourceChange.prev = targetChange.rev; sourceChange.save(cb); }); From da9b7242222acc87268983aac0c813e81d710cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jul 2014 09:31:16 +0200 Subject: [PATCH 72/74] lib/application: publish Change models to REST API When a public model is added to an application and the model has change tracking enabled, its Change model is added to the public models. Before this change, conflict resolution in the browser was not working, because it was not possible to fetch the remote change. --- lib/application.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/application.js b/lib/application.js index d588fafdc..228d4d1a9 100644 --- a/lib/application.js +++ b/lib/application.js @@ -154,6 +154,8 @@ app.model = function (Model, config) { if (isPublic && Model.sharedClass) { this.remotes().addClass(Model.sharedClass); + if (Model.settings.trackChanges && Model.Change) + this.remotes().addClass(Model.Change.sharedClass); clearHandlerCache(this); } From e18ee116c115f65b90c0032fac87c033fb7c48d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jul 2014 20:53:03 +0200 Subject: [PATCH 73/74] 2.0.0-beta6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 64558b92e..67b4e0a0a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "mobile", "mBaaS" ], - "version": "2.0.0-beta5", + "version": "2.0.0-beta6", "scripts": { "test": "grunt mocha-and-karma" }, From ff9bc10c06523e56b87a36a96162955e8c9f35d4 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 16 Jul 2014 09:09:50 -0700 Subject: [PATCH 74/74] Bump version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c5bf9b67e..f7496020e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "mobile", "mBaaS" ], - "version": "2.0.0-beta6", + "version": "2.0.0-beta7", "scripts": { "test": "grunt mocha-and-karma" },