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/CHANGES.md b/CHANGES.md new file mode 100644 index 000000000..04210ac03 --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,29 @@ +# 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`. 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/docs.json b/docs.json index 8c0894b55..6df5005f7 100644 --- a/docs.json +++ b/docs.json @@ -13,6 +13,9 @@ "lib/models/data-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", "docs/api-model-remote.md" ], 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/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/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..31b932733 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -2,9 +2,10 @@ * Dependencies. */ -var assert = require('assert') - , compat = require('../compat') - , _ = require('underscore'); +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. @@ -24,6 +25,10 @@ function RemoteConnector(settings) { this.root = settings.root || ''; this.host = settings.host || 'localhost'; 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; @@ -31,14 +36,14 @@ 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() { + this.remotes.connect(this.url, this.adapter); } - RemoteConnector.initialize = function(dataSource, callback) { var connector = dataSource.connector = new RemoteConnector(dataSource.settings); connector.connect(); @@ -47,22 +52,40 @@ 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; - - Model.remotes(function(err, remotes) { - var sharedClass = getSharedClass(remotes, className); - remotes.connect(url, adapter); - sharedClass - .methods() - .forEach(Model.createProxyMethod.bind(Model)); - }); + var remotes = this.remotes; + var SharedClass; + + 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 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]; + + 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); + } } + function noop() {} diff --git a/lib/loopback.js b/lib/loopback.js index 95d297ba5..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); @@ -320,6 +320,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/acl.js b/lib/models/acl.js index ce6f936f6..6c2456bb4 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/change.js b/lib/models/change.js new file mode 100644 index 000000000..60a6042ee --- /dev/null +++ b/lib/models/change.js @@ -0,0 +1,646 @@ +/** + * Module Dependencies. + */ + +var DataModel = require('./data-model') + , loopback = require('../loopback') + , crypto = require('crypto') + , CJSON = {stringify: require('canonical-json')} + , async = require('async') + , assert = require('assert') + , debug = require('debug')('loopback:change'); + +/** + * 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 = { + trackChanges: false +}; + +/** + * 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 = DataModel.extend('Change', properties, options); + +/*! + * Constants + */ + +Change.UPDATE = 'update'; +Change.CREATE = 'create'; +Change.DELETE = 'delete'; +Change.UNKNOWN = 'unknown'; + +/*! + * Conflict Class + */ + +Change.Conflict = Conflict; + +/*! + * Setup the extended model. + */ + +Change.setup = function() { + DataModel.setup.call(this); + 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.rectifyModelChanges = function(modelName, modelIds, callback) { + var tasks = []; + var Change = this; + + modelIds.forEach(function(id) { + tasks.push(function(cb) { + Change.findOrCreateChange(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.findOrCreateChange = function(modelName, modelId, callback) { + assert(loopback.getModel(modelName), modelName + ' does not exist'); + 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.debug('creating change'); + 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; + var tasks = [ + updateRevision, + updateCheckpoint + ]; + var currentRev = 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); + 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); + 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; + } + } + change.debug('updated revision (was ' + currentRev + ')'); + cb(); + }); + } + + function updateCheckpoint(cb) { + change.constructor.getCheckpointModel().current(function(err, checkpoint) { + if(err) return Change.handleError(err); + change.checkpoint = checkpoint; + 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(); + var id = this.getModelId(); + model.findById(id, 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; +} + +/** + * Compare two changes. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.equals = function(change) { + if(!change) return false; + var thisRev = this.rev || null; + var thatRev = change.rev || null; + 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 + * @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: {gte: since} + } + }, function(err, localChanges) { + if(err) return callback(err); + var deltas = []; + var conflicts = []; + var localModelIds = []; + + localChanges.forEach(function(localChange) { + localChange = new Change(localChange); + localModelIds.push(localChange.modelId); + var remoteChange = remoteChangeIndex[localChange.modelId]; + if(remoteChange && !localChange.equals(remoteChange)) { + if(remoteChange.conflictsWith(localChange)) { + remoteChange.debug('remote conflict'); + localChange.debug('local conflict'); + conflicts.push(localChange); + } else { + remoteChange.debug('remote delta'); + deltas.push(remoteChange); + } + } + }); + + 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) { + debug('rectify all'); + 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(); + }); + }); +} + +/** + * Get the checkpoint model. + * @return {Checkpoint} + */ + +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; +} + +Change.handleError = function(err) { + if(!this.settings.ignoreErrors) { + throw 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} + */ + +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.getModelCtor(); + 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 {*} modelId + * @param {DataModel} SourceModel + * @param {DataModel} TargetModel + * @property {ModelClass} source The source model instance + * @property {ModelClass} target The target model instance + */ + +function Conflict(modelId, SourceModel, TargetModel) { + this.SourceModel = SourceModel; + this.TargetModel = TargetModel; + this.SourceChange = SourceModel.getChangeModel(); + this.TargetChange = TargetModel.getChangeModel(); + this.modelId = modelId; +} + +/** + * Fetch the conflicting models. + * + * @callback {Function} callback + * @param {Error} + * @param {DataModel} source + * @param {DataModel} target + */ + +Conflict.prototype.models = function(cb) { + var conflict = this; + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var source; + var target; + + async.parallel([ + getSourceModel, + getTargetModel + ], done); + + function getSourceModel(cb) { + SourceModel.findById(conflict.modelId, function(err, model) { + if(err) return cb(err); + source = model; + cb(); + }); + } + + function getTargetModel(cb) { + TargetModel.findById(conflict.modelId, function(err, model) { + if(err) return cb(err); + 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({where: { + modelId: conflict.modelId + }}, function(err, change) { + if(err) return cb(err); + sourceChange = change; + cb(); + }); + } + + function getTargetChange(cb) { + conflict.TargetChange.findOne({where: { + modelId: conflict.modelId + }}, 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) { + 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/checkpoint.js b/lib/models/checkpoint.js new file mode 100644 index 000000000..c1fa7d70f --- /dev/null +++ b/lib/models/checkpoint.js @@ -0,0 +1,77 @@ +/** + * Module Dependencies. + */ + +var DataModel = require('../loopback').DataModel + , loopback = require('../loopback') + , assert = require('assert'); + +/** + * Properties + */ + +var properties = { + seq: {type: Number}, + time: {type: Date, default: Date}, + 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 {DataModel} + */ + +var Checkpoint = module.exports = DataModel.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) { + var Checkpoint = this; + this.find({ + limit: 1, + sort: 'seq DESC' + }, function(err, checkpoints) { + if(err) return cb(err); + 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 4c0aeafd0..b501866e3 100644 --- a/lib/models/data-model.js +++ b/lib/models/data-model.js @@ -1,8 +1,12 @@ /*! * Module Dependencies. */ + var Model = require('./model'); -var DataAccess = require('loopback-datasource-juggler/lib/dao'); +var loopback = require('../loopback'); +var RemoteObjects = require('strong-remoting'); +var assert = require('assert'); +var async = require('async'); /** * Extends Model with basic query and CRUD support. @@ -25,22 +29,30 @@ var DataAccess = require('loopback-datasource-juggler/lib/dao'); var DataModel = module.exports = Model.extend('DataModel'); /*! - * Configure the remoting attributes for a given function - * @param {Function} fn The function - * @param {Object} options The options - * @private + * Setup the `DataModel` constructor. */ -function setRemoting(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } +DataModel.setup = function setupDataModel() { + // call Model.setup first + Model.setup.call(this); + + 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; + }); + + // enable change tracking (usually for replication) + if(this.settings.trackChanges) { + DataModel._defineChangeModel(); + DataModel.once('dataSourceAttached', function() { + DataModel.enableChangeTracking(); + }); } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; + + DataModel.setupRemoting(); } /*! @@ -87,13 +99,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 @@ -101,19 +106,11 @@ setRemoting(DataModel.create, { */ DataModel.upsert = DataModel.updateOrCreate = function upsert(data, callback) { - throwNotAttached(this.modelName, 'updateOrCreate'); + throwNotAttached(this.modelName, 'upsert'); }; -// 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 +133,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 * @@ -152,18 +141,9 @@ setRemoting(DataModel.exists, { */ DataModel.findById = function find(id, cb) { - throwNotAttached(this.modelName, 'find'); + throwNotAttached(this.modelName, 'findById'); }; -// 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 +166,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 +177,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 @@ -236,13 +201,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 +212,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 +220,66 @@ setRemoting(DataModel.count, { */ DataModel.prototype.save = function (options, callback) { - var inst = this; - var DataModel = inst.constructor; + var Model = this.constructor; - if(typeof options === 'function') { + 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); + 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); + } +}; /** * Determine if the data model is new. @@ -309,6 +302,8 @@ DataModel.prototype.destroy = function (cb) { throwNotAttached(this.constructor.modelName, 'destroy'); }; +DataModel.prototype.destroy._delegate = true; + /** * Update single attribute * @@ -337,14 +332,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 +344,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 +357,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 +371,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 +380,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 +395,561 @@ DataModel.getIdName = function() { return 'id'; } } + +DataModel.setupRemoting = function() { + var DataModel = this; + var typeName = DataModel.modelName; + var options = DataModel.settings; + + function setRemoting(scope, name, options) { + var fn = scope[name]; + fn._delegate = true; + options.isStatic = scope === DataModel; + DataModel.remoteMethod(name, options); + } + + 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: 'del', path: '/'}, + shared: false + }); + + 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', { + 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: '/'} + }); + + 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: 'result', type: 'object', 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, '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'} + }); + } +} + +/** + * 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, cb) { + if(typeof since === 'function') { + filter = {}; + cb = since; + since = -1; + } + if(typeof filter === 'function') { + cb = filter; + since = -1; + filter = {}; + } + + 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; + + // 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.getModelId(); + }); + 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(); + }); + cb(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 = [ + getSourceChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate, + checkpoint + ]; + + async.waterfall(tasks, done); + + function getSourceChanges(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 { + // nothing to replicate + done(); + } + } + + function bulkUpdate(updates, cb) { + targetModel.bulkUpdate(updates, cb); + } + + function checkpoint() { + 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); + } +} + +/** + * 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) { + console.error('missing data for change:', change); + return cb && cb(new Error('missing data for change: ' + + change.modelId)); + } + 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() { + 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.afterSave = function afterSave(next) { + Model.rectifyChange(this.getId(), next); + } + + Model.afterDestroy = function afterDestroy(next) { + Model.rectifyChange(this.getId(), next); + } + + Model.on('deletedAll', cleanup); + + if(loopback.isServer) { + // initial cleanup + cleanup(); + + // cleanup + setInterval(cleanup, cleanupInterval); + + 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', + {}, + { + trackModel: 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(); diff --git a/lib/models/model.js b/lib/models/model.js index 742c34385..374cd43ef 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -3,29 +3,105 @@ */ 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'); +var SharedClass = require('strong-remoting').SharedClass; /** - * 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'); -Model.shared = true; - /*! * Called when a model is extended. */ Model.setup = function () { var ModelCtor = this; - + var options = this.settings; + + // 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; @@ -65,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; @@ -99,19 +187,21 @@ 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'}} - ]; + // 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); + } + } + }); - ModelCtor.sharedCtor.http = [ - {path: '/:id'} - ]; - - ModelCtor.sharedCtor.returns = {root: true}; - return ModelCtor; }; @@ -145,6 +235,7 @@ Model._ACL = function getACL(ACL) { * @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; @@ -223,50 +314,59 @@ Model.getApp = function(callback) { } /** - * Get the Model's `RemoteObjects`. - * - * @callback {Function} callback - * @param {Error} err - * @param {RemoteObjects} remoteObjects - * @end + * 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'); + * @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.remotes = function(callback) { - this.getApp(function(err, app) { - callback(null, app.remotes()); - }); +Model.remoteMethod = function(name, options) { + if(options.isStatic === undefined) { + options.isStatic = true; + } + this.sharedClass.defineMethod(name, options); } -/*! - * Create a proxy function for invoking remote methods. - * - * @param {SharedMethod} sharedMethod - */ +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.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; +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/lib/models/user.js b/lib/models/user.js index 73a18d39b..ef612403b 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`. @@ -415,7 +415,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/package.json b/package.json index b708344ac..6168653f6 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,14 @@ "Platform", "mBaaS" ], - "version": "1.8.3", + "version": "1.9.0-rc-2", "scripts": { "test": "mocha -R spec" }, "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", @@ -27,13 +27,14 @@ "underscore.string": "~2.3.3", "underscore": "~1.6.0", "uid2": "0.0.3", - "async": "~0.2.10" + "async": "~0.2.10", + "canonical-json": "0.0.3" }, "peerDependencies": { - "loopback-datasource-juggler": "^1.4.0 < 1.6.0" + "loopback-datasource-juggler": "~1.6.0" }, "devDependencies": { - "loopback-datasource-juggler": "^1.4.0 < 1.6.0", + "loopback-datasource-juggler": "~1.6.0", "mocha": "~1.17.1", "strong-task-emitter": "0.0.x", "supertest": "~0.9.0", @@ -53,7 +54,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/access-token.test.js b/test/access-token.test.js index 50cf4d9a4..cd4bc0d63 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.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 ce75a12bd..0f4f8597b 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/app.test.js b/test/app.test.js index 47f317954..41d6285cf 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,30 +13,35 @@ 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); - expect(app.remotes().exports).to.eql({ color: Color }); + Color.attachTo(db); + 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) { 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); }); }); @@ -50,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 new file mode 100644 index 000000000..e0c53eeba --- /dev/null +++ b/test/change.test.js @@ -0,0 +1,291 @@ +var Change; +var TestModel; + +describe('Change', function(){ + beforeEach(function() { + var memory = loopback.createDataSource({ + connector: loopback.Memory + }); + TestModel = loopback.DataModel.extend('chtest', {}, { + trackChanges: true + }); + this.modelName = TestModel.modelName; + TestModel.attachTo(memory); + Change = TestModel.getChangeModel(); + }); + + 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.rectifyModelChanges(modelName, modelIds, callback)', function () { + describe('using an existing untracked model', function () { + beforeEach(function(done) { + var test = this; + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { + if(err) return done(err); + done(); + }); + }); + + 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) { + Change.count(function(err, count) { + assert.equal(count, 1); + done(); + }); + }); + }); + }); + + describe('Change.findOrCreateChange(modelName, modelId, callback)', function () { + + describe('when a change doesnt exist', function () { + beforeEach(function(done) { + var test = this; + Change.findOrCreateChange(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.findOrCreateChange(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); + }); + + 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 () { + 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 = [ + // 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}, + ]; + + Change.diff(this.modelName, 0, remoteChanges, function(err, diff) { + assert.equal(diff.deltas.length, 1); + assert.equal(diff.conflicts.length, 1); + done(); + }); + }); + }); +}); diff --git a/test/data-source.test.js b/test/data-source.test.js index eb0e15375..552da12dd 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -36,38 +36,43 @@ describe('DataSource', function() { }); }); - describe('dataSource.operations()', function() { - it("List the enabled and disabled operations", function() { + describe.skip('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, '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(name, isRemoteEnabled) { - var op = memory.getOperation(name); - assert(op.remoteEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); + 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(actuallyEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); } }); }); diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 3fb806fb3..47c0d7bb0 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); }); @@ -32,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/e2e/replication.e2e.js b/test/e2e/replication.e2e.js new file mode 100644 index 000000000..fc42def4c --- /dev/null +++ b/test/e2e/replication.e2e.js @@ -0,0 +1,37 @@ +var path = require('path'); +var loopback = require('../../'); +var models = require('../fixtures/e2e/models'); +var TestModel = models.TestModel; +var LocalTestModel = TestModel.extend('LocalTestModel', {}, { + trackChanges: true +}); +var assert = require('assert'); + +describe('Replication', function() { + before(function() { + // setup the remote connector + var ds = loopback.createDataSource({ + url: 'http://localhost:3000/api', + connector: loopback.Remote + }); + TestModel.attachTo(ds); + var memory = loopback.memory(); + LocalTestModel.attachTo(memory); + }); + + it('should replicate local data to the remote', function (done) { + var RANDOM = Math.random(); + + LocalTestModel.create({ + n: RANDOM + }, function(err, created) { + LocalTestModel.replicate(0, TestModel, function() { + 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..608b3d7e9 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/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 d1ed50976..b00f41de3 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,85 +1,40 @@ -require('./support'); -var ACL = require('../').ACL; +var async = require('async'); var loopback = require('../'); - -describe('Model', function() { - - var User, memory; - - beforeEach(function () { - memory = loopback.createDataSource({connector: loopback.Memory}); - User = memory.createModel('user', { - 'first': String, - 'last': String, - 'age': Number, - 'password': String, - 'gender': String, - 'domain': String, - 'email': String - }); - }); - - describe('Model.validatesPresenceOf(properties...)', function() { - it("Require a model to include a property to be considered valid", function() { - User.validatesPresenceOf('first', 'last', 'age'); - var joe = new User({first: 'joe'}); - assert(joe.isValid() === false, 'model should not validate'); - assert(joe.errors.last, 'should have a missing last error'); - assert(joe.errors.age, 'should have a missing age error'); - }); - }); - - describe('Model.validatesLengthOf(property, options)', function() { - it("Require a property length to be within a specified range", function() { - User.validatesLengthOf('password', {min: 5, message: {min: 'Password is too short'}}); - var joe = new User({password: '1234'}); - assert(joe.isValid() === false, 'model should not be valid'); - assert(joe.errors.password, 'should have password error'); - }); - }); - - describe('Model.validatesInclusionOf(property, options)', function() { - it("Require a value for `property` to be in the specified array", function() { - User.validatesInclusionOf('gender', {in: ['male', 'female']}); - var foo = new User({gender: 'bar'}); - assert(foo.isValid() === false, 'model should not be valid'); - assert(foo.errors.gender, 'should have gender error'); - }); - }); - - describe('Model.validatesExclusionOf(property, options)', function() { - it("Require a value for `property` to not exist in the specified array", function() { - User.validatesExclusionOf('domain', {in: ['www', 'billing', 'admin']}); - var foo = new User({domain: 'www'}); - var bar = new User({domain: 'billing'}); - var bat = new User({domain: 'admin'}); - assert(foo.isValid() === false); - assert(bar.isValid() === false); - assert(bat.isValid() === false); - assert(foo.errors.domain, 'model should have a domain error'); - assert(bat.errors.domain, 'model should have a domain error'); - assert(bat.errors.domain, 'model should have a domain error'); - }); - }); - - describe('Model.validatesNumericalityOf(property, options)', function() { - it("Require a value for `property` to be a specific type of `Number`", function() { - User.validatesNumericalityOf('age', {int: true}); - var joe = new User({age: 10.2}); - assert(joe.isValid() === false); - var bob = new User({age: 0}); - assert(bob.isValid() === true); - assert(joe.errors.age, 'model should have an age error'); - }); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var DataModel = loopback.DataModel; + +describe('Model / DataModel', function() { + defineModelTestsWithDataSource({ + dataSource: { + connector: loopback.Memory + } }); 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'}); var joe2 = new User({email: 'joe@joe.com'}); - + joe.save(function () { joe2.save(function (err) { assert(err, 'should get a validation error'); @@ -91,125 +46,72 @@ describe('Model', function() { }); }); - describe('myModel.isValid()', function() { - it("Validate the model instance", function() { - User.validatesNumericalityOf('age', {int: true}); - var user = new User({first: 'joe', age: 'flarg'}) - var valid = user.isValid(); - assert(valid === false); - assert(user.errors.age, 'model should have age error'); - }); - - it('Asynchronously validate the model', function(done) { - User.validatesNumericalityOf('age', {int: true}); - var user = new User({first: 'joe', age: 'flarg'}) - user.isValid(function (valid) { - assert(valid === false); - assert(user.errors.age, 'model should have age 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}); + var dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); - 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() { - 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); - done(); + MyModel.find(function(err, results) { + assert(results.length === 0, 'should have data access methods after attaching to a data source'); }); }); }); +}); - describe('model.save([options], [callback])', function() { - it("Save an instance of a Model to the attached data source", function(done) { - var joe = new User({first: 'Joe', last: 'Bob'}); - joe.save(function(err, user) { - assert(user.id); - assert(!err); - assert(!user.errors); - done(); - }); - }); - }); +describe.onServer('Remote Methods', 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); - assert.equal(user.first, 'joe'); - - user.updateAttributes({ - first: 'updatedFirst', - last: 'updatedLast' - }, function (err, updatedUser) { - assert(!err); - assert.equal(updatedUser.first, 'updatedFirst'); - assert.equal(updatedUser.last, 'updatedLast'); - assert.equal(updatedUser.age, 100); - done(); - }); - }); - }); - }); + var User; + var dataSource; + var app; - describe('Model.upsert(data, callback)', function() { - it("Update when record with id=data.id found, insert otherwise", function(done) { - User.upsert({first: 'joe', id: 7}, function (err, user) { - assert(!err); - assert.equal(user.first, 'joe'); - - User.upsert({first: 'bob', id: 7}, function (err, updatedUser) { - assert(!err); - assert.equal(updatedUser.first, 'bob'); - done(); - }); - }); + beforeEach(function () { + User = DataModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true }); - }); - 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) { - 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(); - }); - }); - }); - }); + dataSource = loopback.createDataSource({ + connector: loopback.Memory }); - }); - describe('Model.deleteById([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.findById(user.id, function (err, notFound) { - assert(!err); - assert.equal(notFound, null); - done(); - }); - }); - }); - }); - }); + 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('Model.destroyAll(callback)', function() { it("Delete all Model instances from data source", function(done) { (new TaskEmitter()) @@ -220,7 +122,6 @@ describe('Model', function() { .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); @@ -232,93 +133,97 @@ describe('Model', function() { }); }); - describe('Model.findById(id, callback)', function() { - it("Find an instance by id", function(done) { - User.create({first: 'michael', last: 'jordan', id: 23}, function () { - User.findById(23, function (err, user) { - assert.equal(user.id, 23); - assert.equal(user.first, 'michael'); - assert.equal(user.last, 'jordan'); + 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.count([query], callback)', function() { - it("Query count of Model instances in data source", function(done) { - (new TaskEmitter()) - .task(User, 'create', {first: 'jill', age: 100}) - .task(User, 'create', {first: 'bob', age: 200}) - .task(User, 'create', {first: 'jan'}) - .task(User, 'create', {first: 'sam'}) - .task(User, 'create', {first: 'suzy'}) - .on('done', function () { - User.count({age: {gt: 99}}, function (err, count) { - assert.equal(count, 2); - 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.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'} - } - ); + 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; - 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(); - }); + User.beforeRemote('create', function(ctx, user, next) { + assert(!afterCalled); + beforeCalled = true; + next(); }); - - it('Converts null result of findById to 404 Not Found', function(done) { - request(app) - .get('/users/not-found') - .expect(404) - .end(done); + 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('Model.beforeRemote(name, fn)', function(){ - it('Run a function before a remote method is called by a client', function(done) { + }); + + describe('Remote Method invoking context', function () { + 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') @@ -327,28 +232,27 @@ describe('Model', function() { .expect(200) .end(function(err, res) { if(err) return done(err); - assert(hookCalled, 'hook wasnt called'); + assert(hookCalled); 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; + + describe('ctx.res', function() { + it("The express ServerResponse object", function(done) { + var hookCalled = false; User.beforeRemote('create', function(ctx, user, next) { - assert(!afterCalled); - beforeCalled = true; - next(); - }); - User.afterRemote('create', function(ctx, user, next) { - assert(beforeCalled); - afterCalled = true; + 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') @@ -357,115 +261,48 @@ describe('Model', function() { .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'); + assert(hookCalled); 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; + }); - 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 = []; - it('correctly install before/after hooks', function(done) { - var hooksCalled = []; + User.beforeRemote('**', function(ctx, user, next) { + hooksCalled.push('beforeRemote'); + next(); + }); - User.beforeRemote('**', function(ctx, user, next) { - hooksCalled.push('beforeRemote'); - next(); - }); + User.afterRemote('**', function(ctx, user, next) { + hooksCalled.push('afterRemote'); + 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(); }); - - 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 = 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); @@ -523,7 +360,7 @@ describe('Model', 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 }); @@ -557,33 +394,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); }); }); @@ -615,6 +452,56 @@ describe('Model', function() { } }); + 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.series(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.seq; + cb(err); + }); + } + }); + }); + describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; 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/remote-connector.test.js b/test/remote-connector.test.js index 2a55c02ce..129525b83 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -1,42 +1,70 @@ var loopback = require('../'); +var defineModelTestsWithDataSource = require('./util/model-tests'); describe('RemoteConnector', function() { + var remoteApp; + var remote; + + defineModelTestsWithDataSource({ + beforeEach: function(done) { + var test = this; + remoteApp = loopback(); + 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.createDataSource({ + connector: loopback.Memory + })); + remoteApp.model(RemoteModel); + } + }); + 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); + var test = this; + remoteApp = this.remoteApp = loopback(); remoteApp.use(loopback.rest()); - RemoteModel.attachTo(loopback.memory()); + var ServerModel = this.ServerModel = loopback.DataModel.extend('TestModel'); + + remoteApp.model(ServerModel); + remoteApp.listen(0, function() { - var ds = loopback.createDataSource({ + test.remote = loopback.createDataSource({ host: remoteApp.get('host'), port: remoteApp.get('port'), connector: loopback.Remote }); - - LocalModel.attachTo(ds); done(); }); }); - 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 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(); - 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'}); + var m = new RemoteModel({foo: 'bar'}); + m.save(function(err, inst) { + assert(inst instanceof RemoteModel); + assert(calledServerCreate); done(); }); }); diff --git a/test/replication.test.js b/test/replication.test.js new file mode 100644 index 000000000..c22b1396b --- /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); + }); + }); +}); diff --git a/test/support.js b/test/support.js index 6737119a6..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 @@ -49,19 +50,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/util/model-tests.js b/test/util/model-tests.js new file mode 100644 index 000000000..c8948aa64 --- /dev/null +++ b/test/util/model-tests.js @@ -0,0 +1,247 @@ +var async = require('async'); +var describe = require('./describe'); +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) { + beforeEach(options.beforeEach); + } + + beforeEach(function() { + var test = this; + + // setup a model / datasource + dataSource = this.dataSource || loopback.createDataSource(options.dataSource); + + var extend = DataModel.extend; + + // create model hook + DataModel.extend = function() { + var extendedModel = extend.apply(DataModel, arguments); + + if(options.onDefine) { + options.onDefine.call(test, extendedModel); + } + + return extendedModel; + } + + User = DataModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true + }); + + // enable destroy all for testing + User.destroyAll.shared = true; + User.attachTo(dataSource); + }); + + describe('Model.validatesPresenceOf(properties...)', function() { + it("Require a model to include a property to be considered valid", function() { + User.validatesPresenceOf('first', 'last', 'age'); + var joe = new User({first: 'joe'}); + assert(joe.isValid() === false, 'model should not validate'); + assert(joe.errors.last, 'should have a missing last error'); + assert(joe.errors.age, 'should have a missing age error'); + }); + }); + + describe('Model.validatesLengthOf(property, options)', function() { + it("Require a property length to be within a specified range", function() { + User.validatesLengthOf('password', {min: 5, message: {min: 'Password is too short'}}); + var joe = new User({password: '1234'}); + assert(joe.isValid() === false, 'model should not be valid'); + assert(joe.errors.password, 'should have password error'); + }); + }); + + describe('Model.validatesInclusionOf(property, options)', function() { + it("Require a value for `property` to be in the specified array", function() { + User.validatesInclusionOf('gender', {in: ['male', 'female']}); + var foo = new User({gender: 'bar'}); + assert(foo.isValid() === false, 'model should not be valid'); + assert(foo.errors.gender, 'should have gender error'); + }); + }); + + describe('Model.validatesExclusionOf(property, options)', function() { + it("Require a value for `property` to not exist in the specified array", function() { + User.validatesExclusionOf('domain', {in: ['www', 'billing', 'admin']}); + var foo = new User({domain: 'www'}); + var bar = new User({domain: 'billing'}); + var bat = new User({domain: 'admin'}); + assert(foo.isValid() === false); + assert(bar.isValid() === false); + assert(bat.isValid() === false); + assert(foo.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + }); + }); + + describe('Model.validatesNumericalityOf(property, options)', function() { + it("Require a value for `property` to be a specific type of `Number`", function() { + User.validatesNumericalityOf('age', {int: true}); + var joe = new User({age: 10.2}); + assert(joe.isValid() === false); + var bob = new User({age: 0}); + assert(bob.isValid() === true); + assert(joe.errors.age, 'model should have an age error'); + }); + }); + + describe('myModel.isValid()', function() { + it("Validate the model instance", function() { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}) + var valid = user.isValid(); + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + }); + + it('Asynchronously validate the model', function(done) { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}); + user.isValid(function (valid) { + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + done(); + }); + }); + }); + + 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); + done(); + }); + }); + }); + + describe('model.save([options], [callback])', function() { + it("Save an instance of a Model to the attached data source", function(done) { + var joe = new User({first: 'Joe', last: 'Bob'}); + joe.save(function(err, user) { + assert(user.id); + assert(!err); + assert(!user.errors); + done(); + }); + }); + }); + + 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); + assert.equal(user.first, 'joe'); + + user.updateAttributes({ + first: 'updatedFirst', + last: 'updatedLast' + }, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'updatedFirst'); + assert.equal(updatedUser.last, 'updatedLast'); + assert.equal(updatedUser.age, 100); + done(); + }); + }); + }); + }); + + describe('Model.upsert(data, callback)', function() { + it("Update when record with id=data.id found, insert otherwise", function(done) { + User.upsert({first: 'joe', id: 7}, function (err, user) { + assert(!err); + assert.equal(user.first, 'joe'); + + User.upsert({first: 'bob', id: 7}, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'bob'); + done(); + }); + }); + }); + }); + + 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) { + User.findById(user.id, function (err, foundUser) { + assert.equal(user.id, foundUser.id); + foundUser.destroy(function () { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + }); + + 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.removeById(user.id, function (err) { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + + describe('Model.findById(id, callback)', function() { + it("Find an instance by id", function(done) { + User.create({first: 'michael', last: 'jordan', id: 23}, function () { + User.findById(23, function (err, user) { + assert.equal(user.id, 23); + assert.equal(user.first, 'michael'); + assert.equal(user.last, 'jordan'); + done(); + }); + }); + }); + }); + + describe('Model.count([query], callback)', function() { + it("Query count of Model instances in data source", function(done) { + (new TaskEmitter()) + .task(User, 'create', {first: 'jill', age: 100}) + .task(User, 'create', {first: 'bob', age: 200}) + .task(User, 'create', {first: 'jan'}) + .task(User, 'create', {first: 'sam'}) + .task(User, 'create', {first: 'suzy'}) + .on('done', function () { + User.count({age: {gt: 99}}, function (err, count) { + assert.equal(count, 2); + done(); + }); + }); + }); + }); + +}); + + +}