From c72c134d80e297ffa917304b9f1f5b74a7d23c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 3 Apr 2015 09:26:19 +0200 Subject: [PATCH] Refactor Model and PersistedModel registration Modify the files to export a model factory function accepting a `registry` argument. This is a preparation step for per-application models - see #1212. --- lib/model.js | 1538 +++++++++++------------ lib/persisted-model.js | 2681 ++++++++++++++++++++-------------------- lib/registry.js | 4 +- 3 files changed, 2116 insertions(+), 2107 deletions(-) diff --git a/lib/model.js b/lib/model.js index 49869e7f..39b8428d 100644 --- a/lib/model.js +++ b/lib/model.js @@ -1,811 +1,815 @@ /*! * Module Dependencies. */ -var registry = require('./registry'); var assert = require('assert'); var RemoteObjects = require('strong-remoting'); var SharedClass = require('strong-remoting').SharedClass; var extend = require('util')._extend; -/** - * 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. - * Argument: `inst`, model instance, object - * - * ```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. - * Argument: `id`, model ID (number). - * - * ```js - * MyModel.on('deleted', function(id) { - * console.log('model with id %s has been deleted', id); - * // => model with id 1 has been deleted - * }); - * ``` - * - * #### Event: `deletedAll` - * - * Emitted after an individual model has been deleted. - * Argument: `where` (optional), where filter, JSON object. - * - * ```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`. - * - * #### Event: set - * - * Emitted when model property is set. - * Argument: `inst`, model instance, object - * - * ```js - * MyModel.on('set', function(inst) { - * console.log('model with id %s has been changed', inst.id); - * // => model with id 1 has been changed - * }); - * ``` - * - * @param {Object} data - * @property {String} Model.modelName The name of the model. Static property. - * @property {DataSource} Model.dataSource Data source to which the model is connected, if any. Static property. - * @property {SharedClass} Model.sharedMethod The `strong-remoting` [SharedClass](http://apidocs.strongloop.com/strong-remoting/#sharedclass) that contains remoting (and http) metadata. Static property. - * @property {Object} settings Contains additional model settings. - * @property {string} settings.http.path Base URL of the model HTTP route. - * @property [{string}] settings.acls Array of ACLs for the model. - * @class - */ +module.exports = function(registry) { -var Model = module.exports = registry.modelBuilder.define('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. + * Argument: `inst`, model instance, object + * + * ```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. + * Argument: `id`, model ID (number). + * + * ```js + * MyModel.on('deleted', function(id) { + * console.log('model with id %s has been deleted', id); + * // => model with id 1 has been deleted + * }); + * ``` + * + * #### Event: `deletedAll` + * + * Emitted after an individual model has been deleted. + * Argument: `where` (optional), where filter, JSON object. + * + * ```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`. + * + * #### Event: set + * + * Emitted when model property is set. + * Argument: `inst`, model instance, object + * + * ```js + * MyModel.on('set', function(inst) { + * console.log('model with id %s has been changed', inst.id); + * // => model with id 1 has been changed + * }); + * ``` + * + * @param {Object} data + * @property {String} Model.modelName The name of the model. Static property. + * @property {DataSource} Model.dataSource Data source to which the model is connected, if any. Static property. + * @property {SharedClass} Model.sharedMethod The `strong-remoting` [SharedClass](http://apidocs.strongloop.com/strong-remoting/#sharedclass) that contains remoting (and http) metadata. Static property. + * @property {Object} settings Contains additional model settings. + * @property {string} settings.http.path Base URL of the model HTTP route. + * @property [{string}] settings.acls Array of ACLs for the model. + * @class + */ -/*! - * Called when a model is extended. - */ + var Model = registry.modelBuilder.define('Model'); -Model.setup = function() { - var ModelCtor = this; - var options = this.settings; - var typeName = this.modelName; + /*! + * Called when a model is extended. + */ - var remotingOptions = {}; - extend(remotingOptions, options.remoting || {}); - - // create a sharedClass - var sharedClass = ModelCtor.sharedClass = new SharedClass( - ModelCtor.modelName, - ModelCtor, - remotingOptions - ); - - // setup a remoting type converter for this model - RemoteObjects.convert(typeName, function(val) { - return val ? new ModelCtor(val) : val; - }); - - // support remoting prototype methods - ModelCtor.sharedCtor = function(data, id, fn) { + Model.setup = function() { var ModelCtor = this; + var options = this.settings; + var typeName = this.modelName; - if (typeof data === 'function') { - fn = data; - data = null; - id = null; - } else if (typeof id === 'function') { - fn = id; + var remotingOptions = {}; + extend(remotingOptions, options.remoting || {}); - if (typeof data !== 'object') { - id = data; - data = null; - } else { - id = null; - } - } + // create a sharedClass + var sharedClass = ModelCtor.sharedClass = new SharedClass( + ModelCtor.modelName, + ModelCtor, + remotingOptions + ); - if (id && data) { - var model = new ModelCtor(data); - model.id = id; - fn(null, model); - } else if (data) { - fn(null, new ModelCtor(data)); - } else if (id) { - ModelCtor.findById(id, function(err, model) { - if (err) { - fn(err); - } else if (model) { - fn(null, model); - } else { - err = new Error('could not find a model with id ' + id); - err.statusCode = 404; - err.code = 'MODEL_NOT_FOUND'; - fn(err); - } - }); - } else { - fn(new Error('must specify an id or data')); - } - }; - - var idDesc = ModelCtor.modelName + ' id'; - ModelCtor.sharedCtor.accepts = [ - {arg: 'id', type: 'any', required: true, 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; - if (this.app) { - var remotes = this.app.remotes(); - var className = self.modelName; - remotes.before(className + '.' + name, function(ctx, next) { - fn(ctx, ctx.result, next); - }); - } else { - var args = arguments; - this.once('attached', function() { - self.beforeRemote.apply(self, args); - }); - } - }; - - // after remote hook - ModelCtor.afterRemote = function(name, fn) { - var self = this; - if (this.app) { - var remotes = this.app.remotes(); - var className = self.modelName; - remotes.after(className + '.' + name, function(ctx, next) { - fn(ctx, ctx.result, next); - }); - } else { - var args = arguments; - this.once('attached', function() { - self.afterRemote.apply(self, args); - }); - } - }; - - // resolve relation functions - sharedClass.resolve(function resolver(define) { - - var relations = ModelCtor.relations || {}; - - // get the relations - for (var relationName in relations) { - var relation = relations[relationName]; - if (relation.type === 'belongsTo') { - ModelCtor.belongsToRemoting(relationName, relation, define); - } else if ( - relation.type === 'hasOne' || - relation.type === 'embedsOne' - ) { - ModelCtor.hasOneRemoting(relationName, relation, define); - } else if ( - relation.type === 'hasMany' || - relation.type === 'embedsMany' || - relation.type === 'referencesMany') { - ModelCtor.hasManyRemoting(relationName, relation, define); - } - } - - // handle scopes - var scopes = ModelCtor.scopes || {}; - for (var scopeName in scopes) { - ModelCtor.scopeRemoting(scopeName, scopes[scopeName], define); - } - }); - - return ModelCtor; -}; - -/*! - * Get the reference to ACL in a lazy fashion to avoid race condition in require - */ -var _aclModel = null; -Model._ACL = function getACL(ACL) { - if (ACL !== undefined) { - // The function is used as a setter - _aclModel = ACL; - } - if (_aclModel) { - return _aclModel; - } - var aclModel = registry.getModel('ACL'); - _aclModel = registry.getModelByType(aclModel); - return _aclModel; -}; - -/** - * Check if the given access token can invoke the specified method. - * - * @param {AccessToken} token The access token. - * @param {*} modelId The model ID. - * @param {SharedMethod} sharedMethod The method in question. - * @param {Object} ctx The remote invocation context. - * @callback {Function} callback The callback function. - * @param {String|Error} err The error object. - * @param {Boolean} allowed True if the request is allowed; false otherwise. - */ - -Model.checkAccess = function(token, modelId, sharedMethod, ctx, callback) { - var ANONYMOUS = registry.getModel('AccessToken').ANONYMOUS; - token = token || ANONYMOUS; - var aclModel = Model._ACL(); - - ctx = ctx || {}; - if (typeof ctx === 'function' && callback === undefined) { - callback = ctx; - ctx = {}; - } - - aclModel.checkAccessForContext({ - accessToken: token, - model: this, - property: sharedMethod.name, - method: sharedMethod.name, - sharedMethod: sharedMethod, - modelId: modelId, - accessType: this._getAccessTypeForMethod(sharedMethod), - remotingContext: ctx - }, function(err, accessRequest) { - if (err) return callback(err); - callback(null, accessRequest.isAllowed()); - }); -}; - -/*! - * Determine the access type for the given `RemoteMethod`. - * - * @api private - * @param {RemoteMethod} method - */ - -Model._getAccessTypeForMethod = function(method) { - if (typeof method === 'string') { - method = {name: method}; - } - assert( - typeof method === 'object', - 'method is a required argument and must be a RemoteMethod object' - ); - - var ACL = Model._ACL(); - - // Check the explicit setting of accessType - if (method.accessType) { - assert(method.accessType === ACL.READ || - method.accessType === ACL.WRITE || - method.accessType === ACL.EXECUTE, 'invalid accessType ' + - method.accessType + - '. It must be "READ", "WRITE", or "EXECUTE"'); - return method.accessType; - } - - // Default GET requests to READ - var verb = method.http && method.http.verb; - if (typeof verb === 'string') { - verb = verb.toUpperCase(); - } - if (verb === 'GET' || verb === 'HEAD') { - return ACL.READ; - } - - switch (method.name) { - case'create': - return ACL.WRITE; - case 'updateOrCreate': - return ACL.WRITE; - case 'upsert': - return ACL.WRITE; - case 'exists': - return ACL.READ; - case 'findById': - return ACL.READ; - case 'find': - return ACL.READ; - case 'findOne': - return ACL.READ; - case 'destroyById': - return ACL.WRITE; - case 'deleteById': - return ACL.WRITE; - case 'removeById': - return ACL.WRITE; - case 'count': - return ACL.READ; - default: - return ACL.EXECUTE; - } -}; - -/** - * Get the `Application` object to which the Model is attached. - * - * @callback {Function} callback Callback function called with `(err, app)` arguments. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Application} app Attached application object. - * @end - */ - -Model.getApp = function(callback) { - var Model = this; - if (this.app) { - callback(null, this.app); - } else { - Model.once('attached', function() { - assert(Model.app); - callback(null, Model.app); + // setup a remoting type converter for this model + RemoteObjects.convert(typeName, function(val) { + return val ? new ModelCtor(val) : val; }); - } -}; -/** - * Enable remote invocation for the method with the given name. - * See [Defining remote methods](http://docs.strongloop.com/display/LB/Defining+remote+methods) for more information. - * - * Static method example: - * ```js - * Model.myMethod(); - * Model.remoteMethod('myMethod'); - * ``` - * - * @param {String} name The name of the method. - * @param {Object} options The remoting options. - */ + // support remoting prototype methods + ModelCtor.sharedCtor = function(data, id, fn) { + var ModelCtor = this; -Model.remoteMethod = function(name, options) { - if (options.isStatic === undefined) { - options.isStatic = true; - } - this.sharedClass.defineMethod(name, options); -}; + if (typeof data === 'function') { + fn = data; + data = null; + id = null; + } else if (typeof id === 'function') { + fn = id; -/** - * Disable remote invocation for the method with the given name. - * - * @param {String} name The name of the method. - * @param {Boolean} isStatic Is the method static (eg. `MyModel.myMethod`)? Pass - * `false` if the method defined on the prototype (eg. - * `MyModel.prototype.myMethod`). - */ - -Model.disableRemoteMethod = function(name, isStatic) { - this.sharedClass.disableMethod(name, isStatic || false); -}; - -Model.belongsToRemoting = function(relationName, relation, define) { - var modelName = relation.modelTo && relation.modelTo.modelName; - modelName = modelName || 'PersistedModel'; - var fn = this.prototype[relationName]; - var pathName = (relation.options.http && relation.options.http.path) || relationName; - define('__get__' + relationName, { - isStatic: false, - http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, - accessType: 'READ', - description: 'Fetches belongsTo relation ' + relationName, - returns: {arg: relationName, type: modelName, root: true} - }, fn); -}; - -function convertNullToNotFoundError(toModelName, ctx, cb) { - if (ctx.result !== null) return cb(); - - var fk = ctx.getArgByName('fk'); - var msg = 'Unknown "' + toModelName + '" id "' + fk + '".'; - var error = new Error(msg); - error.statusCode = error.status = 404; - error.code = 'MODEL_NOT_FOUND'; - cb(error); -} - -Model.hasOneRemoting = function(relationName, relation, define) { - var pathName = (relation.options.http && relation.options.http.path) || relationName; - var toModelName = relation.modelTo.modelName; - - define('__get__' + relationName, { - isStatic: false, - http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, - description: 'Fetches hasOne relation ' + relationName, - accessType: 'READ', - returns: {arg: relationName, type: relation.modelTo.modelName, root: true}, - rest: {after: convertNullToNotFoundError.bind(null, toModelName)} - }); - - define('__create__' + relationName, { - isStatic: false, - http: {verb: 'post', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Creates a new instance in ' + relationName + ' of this model.', - accessType: 'WRITE', - returns: {arg: 'data', type: toModelName, root: true} - }); - - define('__update__' + relationName, { - isStatic: false, - http: {verb: 'put', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Update ' + relationName + ' of this model.', - accessType: 'WRITE', - returns: {arg: 'data', type: toModelName, root: true} - }); - - define('__destroy__' + relationName, { - isStatic: false, - http: {verb: 'delete', path: '/' + pathName}, - description: 'Deletes ' + relationName + ' of this model.', - accessType: 'WRITE' - }); -}; - -Model.hasManyRemoting = function(relationName, relation, define) { - var pathName = (relation.options.http && relation.options.http.path) || relationName; - var toModelName = relation.modelTo.modelName; - - var findByIdFunc = this.prototype['__findById__' + relationName]; - define('__findById__' + relationName, { - isStatic: false, - http: {verb: 'get', path: '/' + pathName + '/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Find a related item by id for ' + relationName, - accessType: 'READ', - returns: {arg: 'result', type: toModelName, root: true}, - rest: {after: convertNullToNotFoundError.bind(null, toModelName)} - }, findByIdFunc); - - var destroyByIdFunc = this.prototype['__destroyById__' + relationName]; - define('__destroyById__' + relationName, { - isStatic: false, - http: {verb: 'delete', path: '/' + pathName + '/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Delete a related item by id for ' + relationName, - accessType: 'WRITE', - returns: [] - }, destroyByIdFunc); - - var updateByIdFunc = this.prototype['__updateById__' + relationName]; - define('__updateById__' + relationName, { - isStatic: false, - http: {verb: 'put', path: '/' + pathName + '/:fk'}, - accepts: [ - {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - {arg: 'data', type: toModelName, http: {source: 'body'}} - ], - description: 'Update a related item by id for ' + relationName, - accessType: 'WRITE', - returns: {arg: 'result', type: toModelName, root: true} - }, updateByIdFunc); - - if (relation.modelThrough || relation.type === 'referencesMany') { - var modelThrough = relation.modelThrough || relation.modelTo; - - var accepts = []; - if (relation.type === 'hasMany' && relation.modelThrough) { - // Restrict: only hasManyThrough relation can have additional properties - accepts.push({arg: 'data', type: modelThrough.modelName, http: {source: 'body'}}); - } - - var addFunc = this.prototype['__link__' + relationName]; - define('__link__' + relationName, { - isStatic: false, - http: {verb: 'put', path: '/' + pathName + '/rel/:fk'}, - accepts: [{arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}].concat(accepts), - description: 'Add a related item by id for ' + relationName, - accessType: 'WRITE', - returns: {arg: relationName, type: modelThrough.modelName, root: true} - }, addFunc); - - var removeFunc = this.prototype['__unlink__' + relationName]; - define('__unlink__' + relationName, { - isStatic: false, - http: {verb: 'delete', path: '/' + pathName + '/rel/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Remove the ' + relationName + ' relation to an item by id', - accessType: 'WRITE', - returns: [] - }, removeFunc); - - // FIXME: [rfeng] How to map a function with callback(err, true|false) to HEAD? - // true --> 200 and false --> 404? - var existsFunc = this.prototype['__exists__' + relationName]; - define('__exists__' + relationName, { - isStatic: false, - http: {verb: 'head', path: '/' + pathName + '/rel/:fk'}, - accepts: {arg: 'fk', type: 'any', - description: 'Foreign key for ' + relationName, required: true, - http: {source: 'path'}}, - description: 'Check the existence of ' + relationName + ' relation to an item by id', - accessType: 'READ', - returns: {arg: 'exists', type: 'boolean', root: true}, - rest: { - // After hook to map exists to 200/404 for HEAD - after: function(ctx, cb) { - if (ctx.result === false) { - var modelName = ctx.method.sharedClass.name; - var id = ctx.getArgByName('id'); - var msg = 'Unknown "' + modelName + '" id "' + id + '".'; - var error = new Error(msg); - error.statusCode = error.status = 404; - error.code = 'MODEL_NOT_FOUND'; - cb(error); - } else { - cb(); - } + if (typeof data !== 'object') { + id = data; + data = null; + } else { + id = null; } } - }, existsFunc); - } -}; -Model.scopeRemoting = function(scopeName, scope, define) { - var pathName = - (scope.options && scope.options.http && scope.options.http.path) || scopeName; - - var isStatic = scope.isStatic; - var toModelName = scope.modelTo.modelName; - - // https://github.com/strongloop/loopback/issues/811 - // Check if the scope is for a hasMany relation - var relation = this.relations[scopeName]; - if (relation && relation.modelTo) { - // For a relation with through model, the toModelName should be the one - // from the target model - toModelName = relation.modelTo.modelName; - } - - define('__get__' + scopeName, { - isStatic: isStatic, - http: {verb: 'get', path: '/' + pathName}, - accepts: {arg: 'filter', type: 'object'}, - description: 'Queries ' + scopeName + ' of ' + this.modelName + '.', - accessType: 'READ', - returns: {arg: scopeName, type: [toModelName], root: true} - }); - - define('__create__' + scopeName, { - isStatic: isStatic, - http: {verb: 'post', path: '/' + pathName}, - accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, - description: 'Creates a new instance in ' + scopeName + ' of this model.', - accessType: 'WRITE', - returns: {arg: 'data', type: toModelName, root: true} - }); - - define('__delete__' + scopeName, { - isStatic: isStatic, - http: {verb: 'delete', path: '/' + pathName}, - description: 'Deletes all ' + scopeName + ' of this model.', - accessType: 'WRITE' - }); - - define('__count__' + scopeName, { - isStatic: isStatic, - http: {verb: 'get', path: '/' + pathName + '/count'}, - accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, - description: 'Counts ' + scopeName + ' of ' + this.modelName + '.', - accessType: 'READ', - returns: {arg: 'count', type: 'number'} - }); - -}; - -Model.nestRemoting = function(relationName, options, cb) { - if (typeof options === 'function' && !cb) { - cb = options; - options = {}; - } - options = options || {}; - - var regExp = /^__([^_]+)__([^_]+)$/; - var relation = this.relations[relationName]; - if (relation && relation.modelTo && relation.modelTo.sharedClass) { - var self = this; - var sharedClass = this.sharedClass; - var sharedToClass = relation.modelTo.sharedClass; - var toModelName = relation.modelTo.modelName; - - var pathName = options.pathName || relation.options.path || relationName; - var paramName = options.paramName || 'nk'; - - var http = [].concat(sharedToClass.http || [])[0]; - var httpPath; - var acceptArgs; - - if (relation.multiple) { - httpPath = pathName + '/:' + paramName; - acceptArgs = [ - { - arg: paramName, type: 'any', http: { source: 'path' }, - description: 'Foreign key for ' + relation.name, - required: true - } - ]; - } else { - httpPath = pathName; - acceptArgs = []; - } - - if (httpPath[0] !== '/') { - httpPath = '/' + httpPath; - } - - // A method should return the method name to use, if it is to be - // included as a nested method - a falsy return value will skip. - var filter = cb || options.filterMethod || function(method, relation) { - var matches = method.name.match(regExp); - if (matches) { - return '__' + matches[1] + '__' + relation.name + '__' + matches[2]; + if (id && data) { + var model = new ModelCtor(data); + model.id = id; + fn(null, model); + } else if (data) { + fn(null, new ModelCtor(data)); + } else if (id) { + ModelCtor.findById(id, function(err, model) { + if (err) { + fn(err); + } else if (model) { + fn(null, model); + } else { + err = new Error('could not find a model with id ' + id); + err.statusCode = 404; + err.code = 'MODEL_NOT_FOUND'; + fn(err); + } + }); + } else { + fn(new Error('must specify an id or data')); } }; - sharedToClass.methods().forEach(function(method) { - var methodName; - if (!method.isStatic && (methodName = filter(method, relation))) { - var prefix = relation.multiple ? '__findById__' : '__get__'; - var getterName = options.getterName || (prefix + relationName); + var idDesc = ModelCtor.modelName + ' id'; + ModelCtor.sharedCtor.accepts = [ + {arg: 'id', type: 'any', required: true, http: {source: 'path'}, + description: idDesc} + // {arg: 'instance', type: 'object', http: {source: 'body'}} + ]; - var getterFn = relation.modelFrom.prototype[getterName]; - if (typeof getterFn !== 'function') { - throw new Error('Invalid remote method: `' + getterName + '`'); - } + ModelCtor.sharedCtor.http = [ + {path: '/:id'} + ]; - var nestedFn = relation.modelTo.prototype[method.name]; - if (typeof nestedFn !== 'function') { - throw new Error('Invalid remote method: `' + method.name + '`'); - } + ModelCtor.sharedCtor.returns = {root: true}; - var opts = {}; - - opts.accepts = acceptArgs.concat(method.accepts || []); - opts.returns = [].concat(method.returns || []); - opts.description = method.description; - opts.accessType = method.accessType; - opts.rest = extend({}, method.rest || {}); - opts.rest.delegateTo = method; - - opts.http = []; - var routes = [].concat(method.http || []); - routes.forEach(function(route) { - if (route.path) { - var copy = extend({}, route); - copy.path = httpPath + route.path; - opts.http.push(copy); - } + // before remote hook + ModelCtor.beforeRemote = function(name, fn) { + var self = this; + if (this.app) { + var remotes = this.app.remotes(); + var className = self.modelName; + remotes.before(className + '.' + name, function(ctx, next) { + fn(ctx, ctx.result, next); }); + } else { + var args = arguments; + this.once('attached', function() { + self.beforeRemote.apply(self, args); + }); + } + }; - if (relation.multiple) { - sharedClass.defineMethod(methodName, opts, function(fkId) { - var args = Array.prototype.slice.call(arguments, 1); - var last = args[args.length - 1]; - var cb = typeof last === 'function' ? last : null; - this[getterName](fkId, function(err, inst) { - if (err && cb) return cb(err); - if (inst instanceof relation.modelTo) { - try { - nestedFn.apply(inst, args); - } catch (err) { - if (cb) return cb(err); - } - } else if (cb) { - cb(err, null); - } - }); - }, method.isStatic); - } else { - sharedClass.defineMethod(methodName, opts, function() { - var args = Array.prototype.slice.call(arguments); - var last = args[args.length - 1]; - var cb = typeof last === 'function' ? last : null; - this[getterName](function(err, inst) { - if (err && cb) return cb(err); - if (inst instanceof relation.modelTo) { - try { - nestedFn.apply(inst, args); - } catch (err) { - if (cb) return cb(err); - } - } else if (cb) { - cb(err, null); - } - }); - }, method.isStatic); + // after remote hook + ModelCtor.afterRemote = function(name, fn) { + var self = this; + if (this.app) { + var remotes = this.app.remotes(); + var className = self.modelName; + remotes.after(className + '.' + name, function(ctx, next) { + fn(ctx, ctx.result, next); + }); + } else { + var args = arguments; + this.once('attached', function() { + self.afterRemote.apply(self, args); + }); + } + }; + + // resolve relation functions + sharedClass.resolve(function resolver(define) { + + var relations = ModelCtor.relations || {}; + + // get the relations + for (var relationName in relations) { + var relation = relations[relationName]; + if (relation.type === 'belongsTo') { + ModelCtor.belongsToRemoting(relationName, relation, define); + } else if ( + relation.type === 'hasOne' || + relation.type === 'embedsOne' + ) { + ModelCtor.hasOneRemoting(relationName, relation, define); + } else if ( + relation.type === 'hasMany' || + relation.type === 'embedsMany' || + relation.type === 'referencesMany') { + ModelCtor.hasManyRemoting(relationName, relation, define); } } + + // handle scopes + var scopes = ModelCtor.scopes || {}; + for (var scopeName in scopes) { + ModelCtor.scopeRemoting(scopeName, scopes[scopeName], define); + } }); - if (options.hooks === false) return; // don't inherit before/after hooks + return ModelCtor; + }; - self.once('mounted', function(app, sc, remotes) { - var listenerTree = extend({}, remotes.listenerTree || {}); - listenerTree.before = listenerTree.before || {}; - listenerTree.after = listenerTree.after || {}; + /*! + * Get the reference to ACL in a lazy fashion to avoid race condition in require + */ + var _aclModel = null; + Model._ACL = function getACL(ACL) { + if (ACL !== undefined) { + // The function is used as a setter + _aclModel = ACL; + } + if (_aclModel) { + return _aclModel; + } + var aclModel = registry.getModel('ACL'); + _aclModel = registry.getModelByType(aclModel); + return _aclModel; + }; - var beforeListeners = remotes.listenerTree.before[toModelName] || {}; - var afterListeners = remotes.listenerTree.after[toModelName] || {}; + /** + * Check if the given access token can invoke the specified method. + * + * @param {AccessToken} token The access token. + * @param {*} modelId The model ID. + * @param {SharedMethod} sharedMethod The method in question. + * @param {Object} ctx The remote invocation context. + * @callback {Function} callback The callback function. + * @param {String|Error} err The error object. + * @param {Boolean} allowed True if the request is allowed; false otherwise. + */ - sharedClass.methods().forEach(function(method) { - var delegateTo = method.rest && method.rest.delegateTo; - if (delegateTo && delegateTo.ctor == relation.modelTo) { - var before = method.isStatic ? beforeListeners : beforeListeners['prototype']; - var after = method.isStatic ? afterListeners : afterListeners['prototype']; - var m = method.isStatic ? method.name : 'prototype.' + method.name; - if (before && before[delegateTo.name]) { - self.beforeRemote(m, function(ctx, result, next) { - before[delegateTo.name]._listeners.call(null, ctx, next); - }); + Model.checkAccess = function(token, modelId, sharedMethod, ctx, callback) { + var ANONYMOUS = registry.getModel('AccessToken').ANONYMOUS; + token = token || ANONYMOUS; + var aclModel = Model._ACL(); + + ctx = ctx || {}; + if (typeof ctx === 'function' && callback === undefined) { + callback = ctx; + ctx = {}; + } + + aclModel.checkAccessForContext({ + accessToken: token, + model: this, + property: sharedMethod.name, + method: sharedMethod.name, + sharedMethod: sharedMethod, + modelId: modelId, + accessType: this._getAccessTypeForMethod(sharedMethod), + remotingContext: ctx + }, function(err, accessRequest) { + if (err) return callback(err); + callback(null, accessRequest.isAllowed()); + }); + }; + + /*! + * Determine the access type for the given `RemoteMethod`. + * + * @api private + * @param {RemoteMethod} method + */ + + Model._getAccessTypeForMethod = function(method) { + if (typeof method === 'string') { + method = {name: method}; + } + assert( + typeof method === 'object', + 'method is a required argument and must be a RemoteMethod object' + ); + + var ACL = Model._ACL(); + + // Check the explicit setting of accessType + if (method.accessType) { + assert(method.accessType === ACL.READ || + method.accessType === ACL.WRITE || + method.accessType === ACL.EXECUTE, 'invalid accessType ' + + method.accessType + + '. It must be "READ", "WRITE", or "EXECUTE"'); + return method.accessType; + } + + // Default GET requests to READ + var verb = method.http && method.http.verb; + if (typeof verb === 'string') { + verb = verb.toUpperCase(); + } + if (verb === 'GET' || verb === 'HEAD') { + return ACL.READ; + } + + switch (method.name) { + case'create': + return ACL.WRITE; + case 'updateOrCreate': + return ACL.WRITE; + case 'upsert': + return ACL.WRITE; + case 'exists': + return ACL.READ; + case 'findById': + return ACL.READ; + case 'find': + return ACL.READ; + case 'findOne': + return ACL.READ; + case 'destroyById': + return ACL.WRITE; + case 'deleteById': + return ACL.WRITE; + case 'removeById': + return ACL.WRITE; + case 'count': + return ACL.READ; + default: + return ACL.EXECUTE; + } + }; + + /** + * Get the `Application` object to which the Model is attached. + * + * @callback {Function} callback Callback function called with `(err, app)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Application} app Attached application object. + * @end + */ + + Model.getApp = function(callback) { + var Model = this; + if (this.app) { + callback(null, this.app); + } else { + Model.once('attached', function() { + assert(Model.app); + callback(null, Model.app); + }); + } + }; + + /** + * Enable remote invocation for the method with the given name. + * See [Defining remote methods](http://docs.strongloop.com/display/LB/Defining+remote+methods) for more information. + * + * Static method example: + * ```js + * Model.myMethod(); + * Model.remoteMethod('myMethod'); + * ``` + * + * @param {String} name The name of the method. + * @param {Object} options The remoting options. + */ + + Model.remoteMethod = function(name, options) { + if (options.isStatic === undefined) { + options.isStatic = true; + } + this.sharedClass.defineMethod(name, options); + }; + + /** + * Disable remote invocation for the method with the given name. + * + * @param {String} name The name of the method. + * @param {Boolean} isStatic Is the method static (eg. `MyModel.myMethod`)? Pass + * `false` if the method defined on the prototype (eg. + * `MyModel.prototype.myMethod`). + */ + + Model.disableRemoteMethod = function(name, isStatic) { + this.sharedClass.disableMethod(name, isStatic || false); + }; + + Model.belongsToRemoting = function(relationName, relation, define) { + var modelName = relation.modelTo && relation.modelTo.modelName; + modelName = modelName || 'PersistedModel'; + var fn = this.prototype[relationName]; + var pathName = (relation.options.http && relation.options.http.path) || relationName; + define('__get__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + pathName}, + accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + accessType: 'READ', + description: 'Fetches belongsTo relation ' + relationName, + returns: {arg: relationName, type: modelName, root: true} + }, fn); + }; + + function convertNullToNotFoundError(toModelName, ctx, cb) { + if (ctx.result !== null) return cb(); + + var fk = ctx.getArgByName('fk'); + var msg = 'Unknown "' + toModelName + '" id "' + fk + '".'; + var error = new Error(msg); + error.statusCode = error.status = 404; + error.code = 'MODEL_NOT_FOUND'; + cb(error); + } + + Model.hasOneRemoting = function(relationName, relation, define) { + var pathName = (relation.options.http && relation.options.http.path) || relationName; + var toModelName = relation.modelTo.modelName; + + define('__get__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + pathName}, + accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + description: 'Fetches hasOne relation ' + relationName, + accessType: 'READ', + returns: {arg: relationName, type: relation.modelTo.modelName, root: true}, + rest: {after: convertNullToNotFoundError.bind(null, toModelName)} + }); + + define('__create__' + relationName, { + isStatic: false, + http: {verb: 'post', path: '/' + pathName}, + accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, + description: 'Creates a new instance in ' + relationName + ' of this model.', + accessType: 'WRITE', + returns: {arg: 'data', type: toModelName, root: true} + }); + + define('__update__' + relationName, { + isStatic: false, + http: {verb: 'put', path: '/' + pathName}, + accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, + description: 'Update ' + relationName + ' of this model.', + accessType: 'WRITE', + returns: {arg: 'data', type: toModelName, root: true} + }); + + define('__destroy__' + relationName, { + isStatic: false, + http: {verb: 'delete', path: '/' + pathName}, + description: 'Deletes ' + relationName + ' of this model.', + accessType: 'WRITE' + }); + }; + + Model.hasManyRemoting = function(relationName, relation, define) { + var pathName = (relation.options.http && relation.options.http.path) || relationName; + var toModelName = relation.modelTo.modelName; + + var findByIdFunc = this.prototype['__findById__' + relationName]; + define('__findById__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + pathName + '/:fk'}, + accepts: {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}, + description: 'Find a related item by id for ' + relationName, + accessType: 'READ', + returns: {arg: 'result', type: toModelName, root: true}, + rest: {after: convertNullToNotFoundError.bind(null, toModelName)} + }, findByIdFunc); + + var destroyByIdFunc = this.prototype['__destroyById__' + relationName]; + define('__destroyById__' + relationName, { + isStatic: false, + http: {verb: 'delete', path: '/' + pathName + '/:fk'}, + accepts: {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}, + description: 'Delete a related item by id for ' + relationName, + accessType: 'WRITE', + returns: [] + }, destroyByIdFunc); + + var updateByIdFunc = this.prototype['__updateById__' + relationName]; + define('__updateById__' + relationName, { + isStatic: false, + http: {verb: 'put', path: '/' + pathName + '/:fk'}, + accepts: [ + {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}, + {arg: 'data', type: toModelName, http: {source: 'body'}} + ], + description: 'Update a related item by id for ' + relationName, + accessType: 'WRITE', + returns: {arg: 'result', type: toModelName, root: true} + }, updateByIdFunc); + + if (relation.modelThrough || relation.type === 'referencesMany') { + var modelThrough = relation.modelThrough || relation.modelTo; + + var accepts = []; + if (relation.type === 'hasMany' && relation.modelThrough) { + // Restrict: only hasManyThrough relation can have additional properties + accepts.push({arg: 'data', type: modelThrough.modelName, http: {source: 'body'}}); + } + + var addFunc = this.prototype['__link__' + relationName]; + define('__link__' + relationName, { + isStatic: false, + http: {verb: 'put', path: '/' + pathName + '/rel/:fk'}, + accepts: [{arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}].concat(accepts), + description: 'Add a related item by id for ' + relationName, + accessType: 'WRITE', + returns: {arg: relationName, type: modelThrough.modelName, root: true} + }, addFunc); + + var removeFunc = this.prototype['__unlink__' + relationName]; + define('__unlink__' + relationName, { + isStatic: false, + http: {verb: 'delete', path: '/' + pathName + '/rel/:fk'}, + accepts: {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}, + description: 'Remove the ' + relationName + ' relation to an item by id', + accessType: 'WRITE', + returns: [] + }, removeFunc); + + // FIXME: [rfeng] How to map a function with callback(err, true|false) to HEAD? + // true --> 200 and false --> 404? + var existsFunc = this.prototype['__exists__' + relationName]; + define('__exists__' + relationName, { + isStatic: false, + http: {verb: 'head', path: '/' + pathName + '/rel/:fk'}, + accepts: {arg: 'fk', type: 'any', + description: 'Foreign key for ' + relationName, required: true, + http: {source: 'path'}}, + description: 'Check the existence of ' + relationName + ' relation to an item by id', + accessType: 'READ', + returns: {arg: 'exists', type: 'boolean', root: true}, + rest: { + // After hook to map exists to 200/404 for HEAD + after: function(ctx, cb) { + if (ctx.result === false) { + var modelName = ctx.method.sharedClass.name; + var id = ctx.getArgByName('id'); + var msg = 'Unknown "' + modelName + '" id "' + id + '".'; + var error = new Error(msg); + error.statusCode = error.status = 404; + error.code = 'MODEL_NOT_FOUND'; + cb(error); + } else { + cb(); + } } - if (after && after[delegateTo.name]) { - self.afterRemote(m, function(ctx, result, next) { - after[delegateTo.name]._listeners.call(null, ctx, next); - }); + } + }, existsFunc); + } + }; + + Model.scopeRemoting = function(scopeName, scope, define) { + var pathName = + (scope.options && scope.options.http && scope.options.http.path) || scopeName; + + var isStatic = scope.isStatic; + var toModelName = scope.modelTo.modelName; + + // https://github.com/strongloop/loopback/issues/811 + // Check if the scope is for a hasMany relation + var relation = this.relations[scopeName]; + if (relation && relation.modelTo) { + // For a relation with through model, the toModelName should be the one + // from the target model + toModelName = relation.modelTo.modelName; + } + + define('__get__' + scopeName, { + isStatic: isStatic, + http: {verb: 'get', path: '/' + pathName}, + accepts: {arg: 'filter', type: 'object'}, + description: 'Queries ' + scopeName + ' of ' + this.modelName + '.', + accessType: 'READ', + returns: {arg: scopeName, type: [toModelName], root: true} + }); + + define('__create__' + scopeName, { + isStatic: isStatic, + http: {verb: 'post', path: '/' + pathName}, + accepts: {arg: 'data', type: toModelName, http: {source: 'body'}}, + description: 'Creates a new instance in ' + scopeName + ' of this model.', + accessType: 'WRITE', + returns: {arg: 'data', type: toModelName, root: true} + }); + + define('__delete__' + scopeName, { + isStatic: isStatic, + http: {verb: 'delete', path: '/' + pathName}, + description: 'Deletes all ' + scopeName + ' of this model.', + accessType: 'WRITE' + }); + + define('__count__' + scopeName, { + isStatic: isStatic, + http: {verb: 'get', path: '/' + pathName + '/count'}, + accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + description: 'Counts ' + scopeName + ' of ' + this.modelName + '.', + accessType: 'READ', + returns: {arg: 'count', type: 'number'} + }); + + }; + + Model.nestRemoting = function(relationName, options, cb) { + if (typeof options === 'function' && !cb) { + cb = options; + options = {}; + } + options = options || {}; + + var regExp = /^__([^_]+)__([^_]+)$/; + var relation = this.relations[relationName]; + if (relation && relation.modelTo && relation.modelTo.sharedClass) { + var self = this; + var sharedClass = this.sharedClass; + var sharedToClass = relation.modelTo.sharedClass; + var toModelName = relation.modelTo.modelName; + + var pathName = options.pathName || relation.options.path || relationName; + var paramName = options.paramName || 'nk'; + + var http = [].concat(sharedToClass.http || [])[0]; + var httpPath; + var acceptArgs; + + if (relation.multiple) { + httpPath = pathName + '/:' + paramName; + acceptArgs = [ + { + arg: paramName, type: 'any', http: { source: 'path' }, + description: 'Foreign key for ' + relation.name, + required: true + } + ]; + } else { + httpPath = pathName; + acceptArgs = []; + } + + if (httpPath[0] !== '/') { + httpPath = '/' + httpPath; + } + + // A method should return the method name to use, if it is to be + // included as a nested method - a falsy return value will skip. + var filter = cb || options.filterMethod || function(method, relation) { + var matches = method.name.match(regExp); + if (matches) { + return '__' + matches[1] + '__' + relation.name + '__' + matches[2]; + } + }; + + sharedToClass.methods().forEach(function(method) { + var methodName; + if (!method.isStatic && (methodName = filter(method, relation))) { + var prefix = relation.multiple ? '__findById__' : '__get__'; + var getterName = options.getterName || (prefix + relationName); + + var getterFn = relation.modelFrom.prototype[getterName]; + if (typeof getterFn !== 'function') { + throw new Error('Invalid remote method: `' + getterName + '`'); + } + + var nestedFn = relation.modelTo.prototype[method.name]; + if (typeof nestedFn !== 'function') { + throw new Error('Invalid remote method: `' + method.name + '`'); + } + + var opts = {}; + + opts.accepts = acceptArgs.concat(method.accepts || []); + opts.returns = [].concat(method.returns || []); + opts.description = method.description; + opts.accessType = method.accessType; + opts.rest = extend({}, method.rest || {}); + opts.rest.delegateTo = method; + + opts.http = []; + var routes = [].concat(method.http || []); + routes.forEach(function(route) { + if (route.path) { + var copy = extend({}, route); + copy.path = httpPath + route.path; + opts.http.push(copy); + } + }); + + if (relation.multiple) { + sharedClass.defineMethod(methodName, opts, function(fkId) { + var args = Array.prototype.slice.call(arguments, 1); + var last = args[args.length - 1]; + var cb = typeof last === 'function' ? last : null; + this[getterName](fkId, function(err, inst) { + if (err && cb) return cb(err); + if (inst instanceof relation.modelTo) { + try { + nestedFn.apply(inst, args); + } catch (err) { + if (cb) return cb(err); + } + } else if (cb) { + cb(err, null); + } + }); + }, method.isStatic); + } else { + sharedClass.defineMethod(methodName, opts, function() { + var args = Array.prototype.slice.call(arguments); + var last = args[args.length - 1]; + var cb = typeof last === 'function' ? last : null; + this[getterName](function(err, inst) { + if (err && cb) return cb(err); + if (inst instanceof relation.modelTo) { + try { + nestedFn.apply(inst, args); + } catch (err) { + if (cb) return cb(err); + } + } else if (cb) { + cb(err, null); + } + }); + }, method.isStatic); } } }); - }); - } else { - throw new Error('Relation `' + relationName + '` does not exist for model `' + this.modelName + '`'); - } + if (options.hooks === false) return; // don't inherit before/after hooks + + self.once('mounted', function(app, sc, remotes) { + var listenerTree = extend({}, remotes.listenerTree || {}); + listenerTree.before = listenerTree.before || {}; + listenerTree.after = listenerTree.after || {}; + + var beforeListeners = remotes.listenerTree.before[toModelName] || {}; + var afterListeners = remotes.listenerTree.after[toModelName] || {}; + + sharedClass.methods().forEach(function(method) { + var delegateTo = method.rest && method.rest.delegateTo; + if (delegateTo && delegateTo.ctor == relation.modelTo) { + var before = method.isStatic ? beforeListeners : beforeListeners['prototype']; + var after = method.isStatic ? afterListeners : afterListeners['prototype']; + var m = method.isStatic ? method.name : 'prototype.' + method.name; + if (before && before[delegateTo.name]) { + self.beforeRemote(m, function(ctx, result, next) { + before[delegateTo.name]._listeners.call(null, ctx, next); + }); + } + if (after && after[delegateTo.name]) { + self.afterRemote(m, function(ctx, result, next) { + after[delegateTo.name]._listeners.call(null, ctx, next); + }); + } + } + }); + }); + + } else { + throw new Error('Relation `' + relationName + '` does not exist for model `' + this.modelName + '`'); + } + }; + + Model.ValidationError = require('loopback-datasource-juggler').ValidationError; + + // setup the initial model + Model.setup(); + + return Model; }; - -Model.ValidationError = require('loopback-datasource-juggler').ValidationError; - -// setup the initial model -Model.setup(); diff --git a/lib/persisted-model.js b/lib/persisted-model.js index 36e84b30..34b4158e 100644 --- a/lib/persisted-model.js +++ b/lib/persisted-model.js @@ -2,1444 +2,1449 @@ * Module Dependencies. */ -var Model = require('./model'); -var registry = require('./registry'); var runtime = require('./runtime'); var assert = require('assert'); var async = require('async'); var deprecated = require('depd')('loopback'); var debug = require('debug')('loopback:persisted-model'); -/** - * Extends Model with basic query and CRUD support. - * - * **Change Event** - * - * Listen for model changes using the `change` event. - * - * ```js - * MyPersistedModel.on('changed', function(obj) { - * console.log(obj) // => the changed model - * }); - * ``` - * - * @class PersistedModel - */ +module.exports = function(registry) { -var PersistedModel = module.exports = Model.extend('PersistedModel'); + var Model = registry.Model; -/*! - * Setup the `PersistedModel` constructor. - */ + /** + * Extends Model with basic query and CRUD support. + * + * **Change Event** + * + * Listen for model changes using the `change` event. + * + * ```js + * MyPersistedModel.on('changed', function(obj) { + * console.log(obj) // => the changed model + * }); + * ``` + * + * @class PersistedModel + */ -PersistedModel.setup = function setupPersistedModel() { - // call Model.setup first - Model.setup.call(this); + var PersistedModel = Model.extend('PersistedModel'); - var PersistedModel = this; + /*! + * Setup the `PersistedModel` constructor. + */ - // enable change tracking (usually for replication) - if (this.settings.trackChanges) { - PersistedModel._defineChangeModel(); - PersistedModel.once('dataSourceAttached', function() { - PersistedModel.enableChangeTracking(); - }); - } + PersistedModel.setup = function setupPersistedModel() { + // call Model.setup first + Model.setup.call(this); - PersistedModel.setupRemoting(); -}; + var PersistedModel = this; -/*! - * Throw an error telling the user that the method is not available and why. - */ - -function throwNotAttached(modelName, methodName) { - throw new Error( - 'Cannot call ' + modelName + '.' + methodName + '().' + - ' The ' + methodName + ' method has not been setup.' + - ' The PersistedModel has not been correctly attached to a DataSource!' - ); -} - -/*! - * Convert null callbacks to 404 error objects. - * @param {HttpContext} ctx - * @param {Function} cb - */ - -function convertNullToNotFoundError(ctx, cb) { - if (ctx.result !== null) return cb(); - - var modelName = ctx.method.sharedClass.name; - var id = ctx.getArgByName('id'); - var msg = 'Unknown "' + modelName + '" id "' + id + '".'; - var error = new Error(msg); - error.statusCode = error.status = 404; - error.code = 'MODEL_NOT_FOUND'; - cb(error); -} - -/** - * Create new instance of Model, and save to database. - * - * @param {Object}|[{Object}] data Optional data argument. Can be either a single model instance or an array of instances. - * - * @callback {Function} callback Callback function called with `cb(err, obj)` signature. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} models Model instances or null. - */ - -PersistedModel.create = function(data, callback) { - throwNotAttached(this.modelName, 'create'); -}; - -/** - * Update or insert a model instance - * @param {Object} data The model instance data to insert. - * @callback {Function} callback Callback function called with `cb(err, obj)` signature. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} model Updated model instance. - */ - -PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { - throwNotAttached(this.modelName, 'upsert'); -}; - -/** - * Find one record matching the optional `where` filter. The same as `find`, but limited to one object. - * Returns an object, not collection. - * If not found, create the object using data provided as second argument. - * - * @param {Object} where Where clause, such as `{where: {test: 'me'}}` - *
see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter). - * @param {Object} data Data to insert if object matching the `where` filter is not found. - * @callback {Function} callback Callback function called with `cb(err, instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Model instance matching the `where` filter, if found. - */ - -PersistedModel.findOrCreate = function findOrCreate(query, data, callback) { - throwNotAttached(this.modelName, 'findOrCreate'); -}; - -PersistedModel.findOrCreate._delegate = true; - -/** - * Check whether a model instance exists in database. - * - * @param {id} id Identifier of object (primary key value). - * - * @callback {Function} callback Callback function called with `(err, exists)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Boolean} exists True if the instance with the specified ID exists; false otherwise. - */ - -PersistedModel.exists = function exists(id, cb) { - throwNotAttached(this.modelName, 'exists'); -}; - -/** - * Find object by ID. - * - * @param {*} id Primary key value - * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Model instance matching the specified ID or null if no instance matches. - */ - -PersistedModel.findById = function find(id, cb) { - throwNotAttached(this.modelName, 'findById'); -}; - -/** - * Find all model instances that match `filter` specification. - * See [Querying models](http://docs.strongloop.com/display/LB/Querying+models). - * - * @options {Object} [filter] Optional Filter JSON object; see below. - * @property {String|Object|Array} fields Identify fields to include in return result. - *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). - * @property {String|Object|Array} include See PersistedModel.include documentation. - *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). - * @property {Number} limit Maximum number of instances to return. - *
See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter). - * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. - *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). - * @property {Number} skip Number of results to skip. - *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). - * @property {Object} where Where clause, like - * ``` - * { where: { key: val, key2: {gt: 'val2'}, ...} } - * ``` - *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). - * - * @callback {Function} callback Callback function called with `(err, returned-instances)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Array} models Model instances matching the filter, or null if none found. - */ - -PersistedModel.find = function find(params, cb) { - throwNotAttached(this.modelName, 'find'); -}; - -/** - * Find one model instance that matches `filter` specification. - * Same as `find`, but limited to one result; - * Returns object, not collection. - * - * @options {Object} [filter] Optional Filter JSON object; see below. - * @property {String|Object|Array} fields Identify fields to include in return result. - *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). - * @property {String|Object|Array} include See PersistedModel.include documentation. - *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). - * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. - *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). - * @property {Number} skip Number of results to skip. - *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). - * @property {Object} where Where clause, like - * ``` - * {where: { key: val, key2: {gt: 'val2'}, ...} } - * ``` - *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). - * - * @callback {Function} callback Callback function called with `(err, returned-instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Array} model First model instance that matches the filter or null if none found. - */ - -PersistedModel.findOne = function findOne(params, cb) { - throwNotAttached(this.modelName, 'findOne'); -}; - -/** - * Destroy all model instances that match the optional `where` specification. - * - * @param {Object} [where] Optional where filter, like: - * ``` - * {where: { key: val, key2: {gt: 'val2'}, ...} } - * ``` - *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). - * - * @callback {Function} callback Optional callback function called with `(err, info)` arguments. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} info Additional information about the command outcome. - * @param {Number} info.count Number of instances (rows, documents) destroyed. - */ - -PersistedModel.destroyAll = function destroyAll(where, cb) { - throwNotAttached(this.modelName, 'destroyAll'); -}; - -/** - * Alias for `destroyAll` - */ -PersistedModel.remove = PersistedModel.destroyAll; - -/** - * Alias for `destroyAll` - */ -PersistedModel.deleteAll = PersistedModel.destroyAll; - -/** - * Update multiple instances that match the where clause. - * - * Example: - * - *```js - * Employee.updateAll({managerId: 'x001'}, {managerId: 'x002'}, function(err, info) { - * ... - * }); - * ``` - * - * @param {Object} [where] Optional `where` filter, like - * ``` - * {where: { key: val, key2: {gt: 'val2'}, ...}} - * ``` - *
see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter). - * @param {Object} data Object containing data to replace matching instances, if any. - * - * @callback {Function} callback Callback function called with `(err, info)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} info Additional information about the command outcome. - * @param {Number} info.count Number of instances (rows, documents) updated. - * - */ -PersistedModel.updateAll = function updateAll(where, data, cb) { - throwNotAttached(this.modelName, 'updateAll'); -}; - -/** - * Alias for updateAll. - */ -PersistedModel.update = PersistedModel.updateAll; - -/** - * Destroy model instance with the specified ID. - * @param {*} id The ID value of model instance to delete. - * @callback {Function} callback Callback function called with `(err)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - */ -PersistedModel.destroyById = function deleteById(id, cb) { - throwNotAttached(this.modelName, 'deleteById'); -}; - -/** - * Alias for destroyById. - */ -PersistedModel.removeById = PersistedModel.destroyById; - -/** - * Alias for destroyById. - */ -PersistedModel.deleteById = PersistedModel.destroyById; - -/** - * Return the number of records that match the optional "where" filter. - * @param {Object} [where] Optional where filter, like - * ``` - * {where: { key: val, key2: {gt: 'val2'}, ...} } - * ``` - *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). - * @callback {Function} callback Callback function called with `(err, count)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Number} count Number of instances updated. - */ - -PersistedModel.count = function(where, cb) { - throwNotAttached(this.modelName, 'count'); -}; - -/** - * Save model instance. If the instance doesn't have an ID, then calls [create](#persistedmodelcreatedata-cb) instead. - * Triggers: validate, save, update, or create. - * @options {Object} [options] See below. - * @property {Boolean} validate Perform validation before saving. Default is true. - * @property {Boolean} throws If true, throw a validation error; WARNING: This can crash Node. - * If false, report the error via callback. Default is false. - * @callback {Function} callback Optional callback function called with `(err, obj)` arguments. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Model instance saved or created. - */ - -PersistedModel.prototype.save = function(options, callback) { - var Model = this.constructor; - - if (typeof options == 'function') { - callback = options; - options = {}; - } - - callback = callback || function() { - }; - options = options || {}; - - if (!('validate' in options)) { - options.validate = true; - } - if (!('throws' in options)) { - options.throws = false; - } - - var inst = this; - var data = inst.toObject(true); - var id = this.getId(); - - if (!id) { - return Model.create(this, callback); - } - - // validate first - if (!options.validate) { - return save(); - } - - inst.isValid(function(valid) { - if (valid) { - save(); - } else { - var err = new Model.ValidationError(inst); - // throws option is dangerous for async usage - if (options.throws) { - throw err; - } - callback(err, inst); + // enable change tracking (usually for replication) + if (this.settings.trackChanges) { + PersistedModel._defineChangeModel(); + PersistedModel.once('dataSourceAttached', function() { + PersistedModel.enableChangeTracking(); + }); } - }); - // 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); + PersistedModel.setupRemoting(); + }; + + /*! + * Throw an error telling the user that the method is not available and why. + */ + + function throwNotAttached(modelName, methodName) { + throw new Error( + 'Cannot call ' + modelName + '.' + methodName + '().' + + ' The ' + methodName + ' method has not been setup.' + + ' The PersistedModel has not been correctly attached to a DataSource!' + ); + } + + /*! + * Convert null callbacks to 404 error objects. + * @param {HttpContext} ctx + * @param {Function} cb + */ + + function convertNullToNotFoundError(ctx, cb) { + if (ctx.result !== null) return cb(); + + var modelName = ctx.method.sharedClass.name; + var id = ctx.getArgByName('id'); + var msg = 'Unknown "' + modelName + '" id "' + id + '".'; + var error = new Error(msg); + error.statusCode = error.status = 404; + error.code = 'MODEL_NOT_FOUND'; + cb(error); + } + + /** + * Create new instance of Model, and save to database. + * + * @param {Object}|[{Object}] data Optional data argument. Can be either a single model instance or an array of instances. + * + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} models Model instances or null. + */ + + PersistedModel.create = function(data, callback) { + throwNotAttached(this.modelName, 'create'); + }; + + /** + * Update or insert a model instance + * @param {Object} data The model instance data to insert. + * @callback {Function} callback Callback function called with `cb(err, obj)` signature. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} model Updated model instance. + */ + + PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { + throwNotAttached(this.modelName, 'upsert'); + }; + + /** + * Find one record matching the optional `where` filter. The same as `find`, but limited to one object. + * Returns an object, not collection. + * If not found, create the object using data provided as second argument. + * + * @param {Object} where Where clause, such as `{where: {test: 'me'}}` + *
see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter). + * @param {Object} data Data to insert if object matching the `where` filter is not found. + * @callback {Function} callback Callback function called with `cb(err, instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Model instance matching the `where` filter, if found. + */ + + PersistedModel.findOrCreate = function findOrCreate(query, data, callback) { + throwNotAttached(this.modelName, 'findOrCreate'); + }; + + PersistedModel.findOrCreate._delegate = true; + + /** + * Check whether a model instance exists in database. + * + * @param {id} id Identifier of object (primary key value). + * + * @callback {Function} callback Callback function called with `(err, exists)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Boolean} exists True if the instance with the specified ID exists; false otherwise. + */ + + PersistedModel.exists = function exists(id, cb) { + throwNotAttached(this.modelName, 'exists'); + }; + + /** + * Find object by ID. + * + * @param {*} id Primary key value + * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Model instance matching the specified ID or null if no instance matches. + */ + + PersistedModel.findById = function find(id, cb) { + throwNotAttached(this.modelName, 'findById'); + }; + + /** + * Find all model instances that match `filter` specification. + * See [Querying models](http://docs.strongloop.com/display/LB/Querying+models). + * + * @options {Object} [filter] Optional Filter JSON object; see below. + * @property {String|Object|Array} fields Identify fields to include in return result. + *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). + * @property {String|Object|Array} include See PersistedModel.include documentation. + *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). + * @property {Number} limit Maximum number of instances to return. + *
See [Limit filter](http://docs.strongloop.com/display/LB/Limit+filter). + * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. + *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). + * @property {Number} skip Number of results to skip. + *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). + * @property {Object} where Where clause, like + * ``` + * { where: { key: val, key2: {gt: 'val2'}, ...} } + * ``` + *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). + * + * @callback {Function} callback Callback function called with `(err, returned-instances)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Array} models Model instances matching the filter, or null if none found. + */ + + PersistedModel.find = function find(params, cb) { + throwNotAttached(this.modelName, 'find'); + }; + + /** + * Find one model instance that matches `filter` specification. + * Same as `find`, but limited to one result; + * Returns object, not collection. + * + * @options {Object} [filter] Optional Filter JSON object; see below. + * @property {String|Object|Array} fields Identify fields to include in return result. + *
See [Fields filter](http://docs.strongloop.com/display/LB/Fields+filter). + * @property {String|Object|Array} include See PersistedModel.include documentation. + *
See [Include filter](http://docs.strongloop.com/display/LB/Include+filter). + * @property {String} order Sort order: either "ASC" for ascending or "DESC" for descending. + *
See [Order filter](http://docs.strongloop.com/display/LB/Order+filter). + * @property {Number} skip Number of results to skip. + *
See [Skip filter](http://docs.strongloop.com/display/LB/Skip+filter). + * @property {Object} where Where clause, like + * ``` + * {where: { key: val, key2: {gt: 'val2'}, ...} } + * ``` + *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). + * + * @callback {Function} callback Callback function called with `(err, returned-instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Array} model First model instance that matches the filter or null if none found. + */ + + PersistedModel.findOne = function findOne(params, cb) { + throwNotAttached(this.modelName, 'findOne'); + }; + + /** + * Destroy all model instances that match the optional `where` specification. + * + * @param {Object} [where] Optional where filter, like: + * ``` + * {where: { key: val, key2: {gt: 'val2'}, ...} } + * ``` + *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). + * + * @callback {Function} callback Optional callback function called with `(err, info)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} info Additional information about the command outcome. + * @param {Number} info.count Number of instances (rows, documents) destroyed. + */ + + PersistedModel.destroyAll = function destroyAll(where, cb) { + throwNotAttached(this.modelName, 'destroyAll'); + }; + + /** + * Alias for `destroyAll` + */ + PersistedModel.remove = PersistedModel.destroyAll; + + /** + * Alias for `destroyAll` + */ + PersistedModel.deleteAll = PersistedModel.destroyAll; + + /** + * Update multiple instances that match the where clause. + * + * Example: + * + *```js + * Employee.updateAll({managerId: 'x001'}, {managerId: 'x002'}, function(err, info) { + * ... + * }); + * ``` + * + * @param {Object} [where] Optional `where` filter, like + * ``` + * {where: { key: val, key2: {gt: 'val2'}, ...}} + * ``` + *
see [Where filter](http://docs.strongloop.com/display/public/LB/Where+filter). + * @param {Object} data Object containing data to replace matching instances, if any. + * + * @callback {Function} callback Callback function called with `(err, info)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} info Additional information about the command outcome. + * @param {Number} info.count Number of instances (rows, documents) updated. + * + */ + PersistedModel.updateAll = function updateAll(where, data, cb) { + throwNotAttached(this.modelName, 'updateAll'); + }; + + /** + * Alias for updateAll. + */ + PersistedModel.update = PersistedModel.updateAll; + + /** + * Destroy model instance with the specified ID. + * @param {*} id The ID value of model instance to delete. + * @callback {Function} callback Callback function called with `(err)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + */ + PersistedModel.destroyById = function deleteById(id, cb) { + throwNotAttached(this.modelName, 'deleteById'); + }; + + /** + * Alias for destroyById. + */ + PersistedModel.removeById = PersistedModel.destroyById; + + /** + * Alias for destroyById. + */ + PersistedModel.deleteById = PersistedModel.destroyById; + + /** + * Return the number of records that match the optional "where" filter. + * @param {Object} [where] Optional where filter, like + * ``` + * {where: { key: val, key2: {gt: 'val2'}, ...} } + * ``` + *
See [Where filter](http://docs.strongloop.com/display/LB/Where+filter). + * @callback {Function} callback Callback function called with `(err, count)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Number} count Number of instances updated. + */ + + PersistedModel.count = function(where, cb) { + throwNotAttached(this.modelName, 'count'); + }; + + /** + * Save model instance. If the instance doesn't have an ID, then calls [create](#persistedmodelcreatedata-cb) instead. + * Triggers: validate, save, update, or create. + * @options {Object} [options] See below. + * @property {Boolean} validate Perform validation before saving. Default is true. + * @property {Boolean} throws If true, throw a validation error; WARNING: This can crash Node. + * If false, report the error via callback. Default is false. + * @callback {Function} callback Optional callback function called with `(err, obj)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Model instance saved or created. + */ + + PersistedModel.prototype.save = function(options, callback) { + var Model = this.constructor; + + if (typeof options == 'function') { + callback = options; + options = {}; + } + + callback = callback || function() { + }; + options = options || {}; + + if (!('validate' in options)) { + options.validate = true; + } + if (!('throws' in options)) { + options.throws = false; + } + + var inst = this; + var data = inst.toObject(true); + var id = this.getId(); + + if (!id) { + return Model.create(this, callback); + } + + // validate first + if (!options.validate) { + return save(); + } + + inst.isValid(function(valid) { + if (valid) { + save(); + } else { + var err = new Model.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); - }, data); - } -}; - -/** - * Determine if the data model is new. - * @returns {Boolean} Returns true if the data model is new; false otherwise. - */ - -PersistedModel.prototype.isNewRecord = function() { - throwNotAttached(this.constructor.modelName, 'isNewRecord'); -}; - -/** - * Deletes the model from persistence. - * Triggers `destroy` hook (async) before and after destroying object. - * @param {Function} callback Callback function. - */ - -PersistedModel.prototype.destroy = function(cb) { - throwNotAttached(this.constructor.modelName, 'destroy'); -}; - -/** - * Alias for destroy. - * @header PersistedModel.remove - */ -PersistedModel.prototype.remove = PersistedModel.prototype.destroy; - -/** - * Alias for destroy. - * @header PersistedModel.delete - */ -PersistedModel.prototype.delete = PersistedModel.prototype.destroy; - -PersistedModel.prototype.destroy._delegate = true; - -/** - * Update a single attribute. - * Equivalent to `updateAttributes({name: 'value'}, cb)` - * - * @param {String} name Name of property. - * @param {Mixed} value Value of property. - * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Updated instance. - */ - -PersistedModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { - throwNotAttached(this.constructor.modelName, 'updateAttribute'); -}; - -/** - * Update set of attributes. Performs validation before updating. - * - * Triggers: `validation`, `save` and `update` hooks - * @param {Object} data Dta to update. - * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Updated instance. - */ - -PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) { - throwNotAttached(this.modelName, 'updateAttributes'); -}; - -/** - * Reload object from persistence. Requires `id` member of `object` to be able to call `find`. - * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} instance Model instance. - */ - -PersistedModel.prototype.reload = function reload(callback) { - throwNotAttached(this.constructor.modelName, 'reload'); -}; - -/** - * Set the correct `id` property for the `PersistedModel`. Uses the `setId` method if the model is attached to - * connector that defines it. Otherwise, uses the default lookup. - * Override this method to handle complex IDs. - * - * @param {*} val The `id` value. Will be converted to the type that the `id` property specifies. - */ - -PersistedModel.prototype.setId = function(val) { - var ds = this.getDataSource(); - this[this.getIdName()] = val; -}; - -/** - * Get the `id` value for the `PersistedModel`. - * - * @returns {*} The `id` value - */ - -PersistedModel.prototype.getId = function() { - var data = this.toObject(); - if (!data) return; - return data[this.getIdName()]; -}; - -/** - * Get the `id` property name of the constructor. - * - * @returns {String} The `id` property name - */ - -PersistedModel.prototype.getIdName = function() { - return this.constructor.getIdName(); -}; - -/** - * Get the `id` property name of the constructor. - * - * @returns {String} The `id` property name - */ - -PersistedModel.getIdName = function() { - var Model = this; - var ds = Model.getDataSource(); - - if (ds.idName) { - return ds.idName(Model.modelName); - } else { - return 'id'; - } -}; - -PersistedModel.setupRemoting = function() { - var PersistedModel = this; - var typeName = PersistedModel.modelName; - var options = PersistedModel.settings; - - function setRemoting(scope, name, options) { - var fn = scope[name]; - fn._delegate = true; - options.isStatic = scope === PersistedModel; - PersistedModel.remoteMethod(name, options); - } - - setRemoting(PersistedModel, 'create', { - description: 'Create a new instance of the model and persist it into the data source', - accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'post', path: '/'} - }); - - setRemoting(PersistedModel, 'upsert', { - aliases: ['updateOrCreate'], - description: 'Update an existing model instance or insert a new one into the data source', - accessType: 'WRITE', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: typeName, root: true}, - http: {verb: 'put', path: '/'} - }); - - setRemoting(PersistedModel, 'exists', { - description: 'Check whether a model instance exists in the data source', - accessType: 'READ', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - returns: {arg: 'exists', type: 'boolean'}, - http: [ - {verb: 'get', path: '/:id/exists'}, - {verb: 'head', path: '/:id'} - ], - rest: { - // After hook to map exists to 200/404 for HEAD - after: function(ctx, cb) { - if (ctx.req.method === 'GET') { - // For GET, return {exists: true|false} as is - return cb(); - } - if (!ctx.result.exists) { - var modelName = ctx.method.sharedClass.name; - var id = ctx.getArgByName('id'); - var msg = 'Unknown "' + modelName + '" id "' + id + '".'; - var error = new Error(msg); - error.statusCode = error.status = 404; - error.code = 'MODEL_NOT_FOUND'; - cb(error); - } else { - cb(); - } - } } - }); - - setRemoting(PersistedModel, 'findById', { - description: 'Find a model instance by id from the data source', - accessType: 'READ', - 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(PersistedModel, 'find', { - description: 'Find all instances of the model matched by filter from the data source', - accessType: 'READ', - 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(PersistedModel, 'findOne', { - description: 'Find first instance of the model matched by filter from the data source', - accessType: 'READ', - 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'}, - rest: {after: convertNullToNotFoundError} - }); - - setRemoting(PersistedModel, 'destroyAll', { - description: 'Delete all matching records', - accessType: 'WRITE', - accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, - http: {verb: 'del', path: '/'}, - shared: false - }); - - setRemoting(PersistedModel, 'updateAll', { - aliases: ['update'], - description: 'Update instances of the model matched by where from the data source', - accessType: 'WRITE', - accepts: [ - {arg: 'where', type: 'object', http: {source: 'query'}, - description: 'Criteria to match model instances'}, - {arg: 'data', type: 'object', http: {source: 'body'}, - description: 'An object of model property name/value pairs'}, - ], - http: {verb: 'post', path: '/update'} - }); - - setRemoting(PersistedModel, 'deleteById', { - aliases: ['destroyById', 'removeById'], - description: 'Delete a model instance by id from the data source', - accessType: 'WRITE', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, - http: {source: 'path'}}, - http: {verb: 'del', path: '/:id'} - }); - - setRemoting(PersistedModel, 'count', { - description: 'Count instances of the model matched by where from the data source', - accessType: 'READ', - accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, - returns: {arg: 'count', type: 'number'}, - http: {verb: 'get', path: '/count'} - }); - - setRemoting(PersistedModel.prototype, 'updateAttributes', { - description: 'Update attributes for a model instance and persist it into the data source', - accessType: 'WRITE', - 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(PersistedModel, 'diff', { - description: 'Get a set of deltas and conflicts since the given checkpoint', - accessType: 'READ', - 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(PersistedModel, 'changes', { - description: 'Get the changes to a model since a given checkpoint.' + - 'Provide a filter object to reduce the number of results returned.', - accessType: 'READ', - 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(PersistedModel, 'checkpoint', { - description: 'Create a checkpoint.', - accessType: 'WRITE', - returns: {arg: 'checkpoint', type: 'object', root: true}, - http: {verb: 'post', path: '/checkpoint'} - }); - - setRemoting(PersistedModel, 'currentCheckpoint', { - description: 'Get the current checkpoint.', - accessType: 'READ', - returns: {arg: 'checkpoint', type: 'object', root: true}, - http: {verb: 'get', path: '/checkpoint'} - }); - - setRemoting(PersistedModel, 'createUpdates', { - description: 'Create an update list from a delta list', - accessType: 'WRITE', - accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, - returns: {arg: 'updates', type: 'array', root: true}, - http: {verb: 'post', path: '/create-updates'} - }); - - setRemoting(PersistedModel, 'bulkUpdate', { - description: 'Run multiple updates at once. Note: this is not atomic.', - accessType: 'WRITE', - accepts: {arg: 'updates', type: 'array'}, - http: {verb: 'post', path: '/bulk-update'} - }); - - setRemoting(PersistedModel, 'rectifyAllChanges', { - description: 'Rectify all Model changes.', - accessType: 'WRITE', - http: {verb: 'post', path: '/rectify-all'} - }); - - setRemoting(PersistedModel, 'rectifyChange', { - description: 'Tell loopback that a change to the model with the given id has occurred.', - accessType: 'WRITE', - 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()](#change-diff) for details. - * - * @param {Number} since Find deltas since this checkpoint. - * @param {Array} remoteChanges An array of change objects. - * @callback {Function} callback Callback function called with `(err, result)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Object} result Object with `deltas` and `conflicts` properties; see [Change.diff()](#change-diff) for details. - */ - -PersistedModel.diff = function(since, remoteChanges, callback) { - var Change = this.getChangeModel(); - Change.diff(this.modelName, since, remoteChanges, callback); -}; - -/** - * Get the changes to a model since the specified checkpoint. Provide a filter object - * to reduce the number of results returned. - * @param {Number} since Return only changes since this checkpoint. - * @param {Object} filter Include only changes that match this filter, the same as for [#persistedmodel-find](find()). - * @callback {Function} callback Callback function called with `(err, changes)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Array} changes An array of [Change](#change) objects. - */ - -PersistedModel.changes = function(since, filter, callback) { - if (typeof since === 'function') { - filter = {}; - callback = since; - since = -1; - } - if (typeof filter === 'function') { - callback = 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({ where: { - checkpoint: { gte: since }, - modelName: this.modelName - }}, function(err, changes) { - if (err) return callback(err); - if (!Array.isArray(changes) || changes.length === 0) return callback(null, []); - var ids = changes.map(function(change) { - return change.getModelId(); - }); - filter.where[idName] = {inq: ids}; - model.find(filter, function(err, models) { - if (err) return callback(err); - var modelIds = models.map(function(m) { - return m[idName].toString(); - }); - callback(null, changes.filter(function(ch) { - if (ch.type() === Change.DELETE) return true; - return modelIds.indexOf(ch.modelId) > -1; - })); - }); - }); -}; - -/** - * Create a checkpoint. - * - * @param {Function} callback - */ - -PersistedModel.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 Callback function called with `(err, currentCheckpointId)` arguments. Required. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Number} currentCheckpointId Current checkpoint ID. - */ - -PersistedModel.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] Callback function called with `(err, conflicts)` arguments. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {Conflict[]} conflicts A list of changes that could not be replicated due to conflicts. - * @param {Object] checkpoints The new checkpoints to use as the "since" - * argument for the next replication. - */ - -PersistedModel.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; - } - - if (typeof since !== 'object') { - since = { source: since, target: since }; - } - - options = options || {}; - - var sourceModel = this; - callback = callback || function defaultReplicationCallback(err) { - if (err) throw err; }; - debug('replicating %s since %s to %s since %s', - sourceModel.modelName, - since.source, - targetModel.modelName, - since.target); - if (options.filter) { - debug('\twith filter %j', options.filter); - } + /** + * Determine if the data model is new. + * @returns {Boolean} Returns true if the data model is new; false otherwise. + */ - // In order to avoid a race condition between the replication and - // other clients modifying the data, we must create the new target - // checkpoint as the first step of the replication process. - // As a side-effect of that, the replicated changes are associated - // with the new target checkpoint. This is actually desired behaviour, - // because that way clients replicating *from* the target model - // since the new checkpoint will pick these changes up. - // However, it increases the likelihood of (false) conflicts being detected. - // In order to prevent that, we run the replication multiple times, - // until no changes were replicated, but at most MAX_ATTEMPTS times - // to prevent starvation. In most cases, the second run will find no changes - // to replicate and we are done. - var MAX_ATTEMPTS = 3; + PersistedModel.prototype.isNewRecord = function() { + throwNotAttached(this.constructor.modelName, 'isNewRecord'); + }; - run(1, since); + /** + * Deletes the model from persistence. + * Triggers `destroy` hook (async) before and after destroying object. + * @param {Function} callback Callback function. + */ - function run(attempt, since) { - debug('\titeration #%s', attempt); - tryReplicate(sourceModel, targetModel, since, options, next); + PersistedModel.prototype.destroy = function(cb) { + throwNotAttached(this.constructor.modelName, 'destroy'); + }; - function next(err, conflicts, cps, updates) { - var finished = err || conflicts.length || - !updates || updates.length === 0 || - attempt >= MAX_ATTEMPTS; + /** + * Alias for destroy. + * @header PersistedModel.remove + */ + PersistedModel.prototype.remove = PersistedModel.prototype.destroy; - if (finished) - return callback(err, conflicts, cps); - run(attempt + 1, cps); - } - } -}; + /** + * Alias for destroy. + * @header PersistedModel.delete + */ + PersistedModel.prototype.delete = PersistedModel.prototype.destroy; -function tryReplicate(sourceModel, targetModel, since, options, callback) { - var Change = sourceModel.getChangeModel(); - var TargetChange = targetModel.getChangeModel(); - var changeTrackingEnabled = Change && TargetChange; + PersistedModel.prototype.destroy._delegate = true; - assert( - changeTrackingEnabled, - 'You must enable change tracking before replicating' - ); + /** + * Update a single attribute. + * Equivalent to `updateAttributes({name: 'value'}, cb)` + * + * @param {String} name Name of property. + * @param {Mixed} value Value of property. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Updated instance. + */ - var diff; - var updates; - var newSourceCp, newTargetCp; + PersistedModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { + throwNotAttached(this.constructor.modelName, 'updateAttribute'); + }; - var tasks = [ - checkpoints, - getSourceChanges, - getDiffFromTarget, - createSourceUpdates, - bulkUpdate - ]; + /** + * Update set of attributes. Performs validation before updating. + * + * Triggers: `validation`, `save` and `update` hooks + * @param {Object} data Dta to update. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Updated instance. + */ - async.waterfall(tasks, done); + PersistedModel.prototype.updateAttributes = function updateAttributes(data, cb) { + throwNotAttached(this.modelName, 'updateAttributes'); + }; - function getSourceChanges(cb) { - sourceModel.changes(since.source, options.filter, debug.enabled ? log : cb); + /** + * Reload object from persistence. Requires `id` member of `object` to be able to call `find`. + * @callback {Function} callback Callback function called with `(err, instance)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} instance Model instance. + */ - function log(err, result) { - if (err) return cb(err); - debug('\tusing source changes'); - result.forEach(function(it) { debug('\t\t%j', it); }); - cb(err, result); - } - } + PersistedModel.prototype.reload = function reload(callback) { + throwNotAttached(this.constructor.modelName, 'reload'); + }; - function getDiffFromTarget(sourceChanges, cb) { - targetModel.diff(since.target, sourceChanges, debug.enabled ? log : cb); + /** + * Set the correct `id` property for the `PersistedModel`. Uses the `setId` method if the model is attached to + * connector that defines it. Otherwise, uses the default lookup. + * Override this method to handle complex IDs. + * + * @param {*} val The `id` value. Will be converted to the type that the `id` property specifies. + */ - function log(err, result) { - if (err) return cb(err); - if (result.conflicts && result.conflicts.length) { - debug('\tdiff conflicts'); - result.conflicts.forEach(function(d) { debug('\t\t%j', d); }); - } - if (result.deltas && result.deltas.length) { - debug('\tdiff deltas'); - result.deltas.forEach(function(it) { debug('\t\t%j', it); }); - } - cb(err, result); - } - } + PersistedModel.prototype.setId = function(val) { + var ds = this.getDataSource(); + this[this.getIdName()] = val; + }; - function createSourceUpdates(_diff, cb) { - diff = _diff; - diff.conflicts = diff.conflicts || []; - if (diff && diff.deltas && diff.deltas.length) { - debug('\tbuilding a list of updates'); - sourceModel.createUpdates(diff.deltas, cb); + /** + * Get the `id` value for the `PersistedModel`. + * + * @returns {*} The `id` value + */ + + PersistedModel.prototype.getId = function() { + var data = this.toObject(); + if (!data) return; + return data[this.getIdName()]; + }; + + /** + * Get the `id` property name of the constructor. + * + * @returns {String} The `id` property name + */ + + PersistedModel.prototype.getIdName = function() { + return this.constructor.getIdName(); + }; + + /** + * Get the `id` property name of the constructor. + * + * @returns {String} The `id` property name + */ + + PersistedModel.getIdName = function() { + var Model = this; + var ds = Model.getDataSource(); + + if (ds.idName) { + return ds.idName(Model.modelName); } else { - // nothing to replicate - done(); + return 'id'; } - } + }; - function bulkUpdate(_updates, cb) { - debug('\tstarting bulk update'); - updates = _updates; - targetModel.bulkUpdate(updates, function(err) { - var conflicts = err && err.details && err.details.conflicts; - if (conflicts && err.statusCode == 409) { - diff.conflicts = conflicts; - // filter out updates that were not applied - updates = updates.filter(function(u) { - return conflicts - .filter(function(d) { return d.modelId === u.change.modelId; }) - .length === 0; - }); - return cb(); - } - cb(err); + PersistedModel.setupRemoting = function() { + var PersistedModel = this; + var typeName = PersistedModel.modelName; + var options = PersistedModel.settings; + + function setRemoting(scope, name, options) { + var fn = scope[name]; + fn._delegate = true; + options.isStatic = scope === PersistedModel; + PersistedModel.remoteMethod(name, options); + } + + setRemoting(PersistedModel, 'create', { + description: 'Create a new instance of the model and persist it into the data source', + accessType: 'WRITE', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'post', path: '/'} }); - } - function checkpoints() { - var cb = arguments[arguments.length - 1]; - sourceModel.checkpoint(function(err, source) { - if (err) return cb(err); - newSourceCp = source.seq; - targetModel.checkpoint(function(err, target) { - if (err) return cb(err); - newTargetCp = target.seq; - debug('\tcreated checkpoints'); - debug('\t\t%s for source model %s', newSourceCp, sourceModel.modelName); - debug('\t\t%s for target model %s', newTargetCp, targetModel.modelName); - cb(); + setRemoting(PersistedModel, 'upsert', { + aliases: ['updateOrCreate'], + description: 'Update an existing model instance or insert a new one into the data source', + accessType: 'WRITE', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'put', path: '/'} + }); + + setRemoting(PersistedModel, 'exists', { + description: 'Check whether a model instance exists in the data source', + accessType: 'READ', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + returns: {arg: 'exists', type: 'boolean'}, + http: [ + {verb: 'get', path: '/:id/exists'}, + {verb: 'head', path: '/:id'} + ], + rest: { + // After hook to map exists to 200/404 for HEAD + after: function(ctx, cb) { + if (ctx.req.method === 'GET') { + // For GET, return {exists: true|false} as is + return cb(); + } + if (!ctx.result.exists) { + var modelName = ctx.method.sharedClass.name; + var id = ctx.getArgByName('id'); + var msg = 'Unknown "' + modelName + '" id "' + id + '".'; + var error = new Error(msg); + error.statusCode = error.status = 404; + error.code = 'MODEL_NOT_FOUND'; + cb(error); + } else { + cb(); + } + } + } + }); + + setRemoting(PersistedModel, 'findById', { + description: 'Find a model instance by id from the data source', + accessType: 'READ', + 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(PersistedModel, 'find', { + description: 'Find all instances of the model matched by filter from the data source', + accessType: 'READ', + 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(PersistedModel, 'findOne', { + description: 'Find first instance of the model matched by filter from the data source', + accessType: 'READ', + 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'}, + rest: {after: convertNullToNotFoundError} + }); + + setRemoting(PersistedModel, 'destroyAll', { + description: 'Delete all matching records', + accessType: 'WRITE', + accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, + http: {verb: 'del', path: '/'}, + shared: false + }); + + setRemoting(PersistedModel, 'updateAll', { + aliases: ['update'], + description: 'Update instances of the model matched by where from the data source', + accessType: 'WRITE', + accepts: [ + {arg: 'where', type: 'object', http: {source: 'query'}, + description: 'Criteria to match model instances'}, + {arg: 'data', type: 'object', http: {source: 'body'}, + description: 'An object of model property name/value pairs'}, + ], + http: {verb: 'post', path: '/update'} + }); + + setRemoting(PersistedModel, 'deleteById', { + aliases: ['destroyById', 'removeById'], + description: 'Delete a model instance by id from the data source', + accessType: 'WRITE', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'}}, + http: {verb: 'del', path: '/:id'} + }); + + setRemoting(PersistedModel, 'count', { + description: 'Count instances of the model matched by where from the data source', + accessType: 'READ', + accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + returns: {arg: 'count', type: 'number'}, + http: {verb: 'get', path: '/count'} + }); + + setRemoting(PersistedModel.prototype, 'updateAttributes', { + description: 'Update attributes for a model instance and persist it into the data source', + accessType: 'WRITE', + 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(PersistedModel, 'diff', { + description: 'Get a set of deltas and conflicts since the given checkpoint', + accessType: 'READ', + 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(PersistedModel, 'changes', { + description: 'Get the changes to a model since a given checkpoint.' + + 'Provide a filter object to reduce the number of results returned.', + accessType: 'READ', + 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(PersistedModel, 'checkpoint', { + description: 'Create a checkpoint.', + accessType: 'WRITE', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'post', path: '/checkpoint'} + }); + + setRemoting(PersistedModel, 'currentCheckpoint', { + description: 'Get the current checkpoint.', + accessType: 'READ', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'get', path: '/checkpoint'} + }); + + setRemoting(PersistedModel, 'createUpdates', { + description: 'Create an update list from a delta list', + accessType: 'WRITE', + accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, + returns: {arg: 'updates', type: 'array', root: true}, + http: {verb: 'post', path: '/create-updates'} + }); + + setRemoting(PersistedModel, 'bulkUpdate', { + description: 'Run multiple updates at once. Note: this is not atomic.', + accessType: 'WRITE', + accepts: {arg: 'updates', type: 'array'}, + http: {verb: 'post', path: '/bulk-update'} + }); + + setRemoting(PersistedModel, 'rectifyAllChanges', { + description: 'Rectify all Model changes.', + accessType: 'WRITE', + http: {verb: 'post', path: '/rectify-all'} + }); + + setRemoting(PersistedModel, 'rectifyChange', { + description: 'Tell loopback that a change to the model with the given id has occurred.', + accessType: 'WRITE', + 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()](#change-diff) for details. + * + * @param {Number} since Find deltas since this checkpoint. + * @param {Array} remoteChanges An array of change objects. + * @callback {Function} callback Callback function called with `(err, result)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Object} result Object with `deltas` and `conflicts` properties; see [Change.diff()](#change-diff) for details. + */ + + PersistedModel.diff = function(since, remoteChanges, callback) { + var Change = this.getChangeModel(); + Change.diff(this.modelName, since, remoteChanges, callback); + }; + + /** + * Get the changes to a model since the specified checkpoint. Provide a filter object + * to reduce the number of results returned. + * @param {Number} since Return only changes since this checkpoint. + * @param {Object} filter Include only changes that match this filter, the same as for [#persistedmodel-find](find()). + * @callback {Function} callback Callback function called with `(err, changes)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Array} changes An array of [Change](#change) objects. + */ + + PersistedModel.changes = function(since, filter, callback) { + if (typeof since === 'function') { + filter = {}; + callback = since; + since = -1; + } + if (typeof filter === 'function') { + callback = 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({ where: { + checkpoint: { gte: since }, + modelName: this.modelName + }}, function(err, changes) { + if (err) return callback(err); + if (!Array.isArray(changes) || changes.length === 0) return callback(null, []); + var ids = changes.map(function(change) { + return change.getModelId(); + }); + filter.where[idName] = {inq: ids}; + model.find(filter, function(err, models) { + if (err) return callback(err); + var modelIds = models.map(function(m) { + return m[idName].toString(); + }); + callback(null, changes.filter(function(ch) { + if (ch.type() === Change.DELETE) return true; + return modelIds.indexOf(ch.modelId) > -1; + })); }); }); - } + }; - function done(err) { - if (err) return callback(err); + /** + * Create a checkpoint. + * + * @param {Function} callback + */ - debug('\treplication finished'); - debug('\t\t%s conflict(s) detected', diff.conflicts.length); - debug('\t\t%s change(s) applied', updates ? updates.length : 0); - debug('\t\tnew checkpoints: { source: %j, target: %j }', - newSourceCp, newTargetCp); - - var conflicts = diff.conflicts.map(function(change) { - return new Change.Conflict( - change.modelId, sourceModel, targetModel - ); + PersistedModel.checkpoint = function(cb) { + var Checkpoint = this.getChangeModel().getCheckpointModel(); + this.getSourceId(function(err, sourceId) { + if (err) return cb(err); + Checkpoint.create({ + sourceId: sourceId + }, cb); }); + }; - if (conflicts.length) { - sourceModel.emit('conflicts', conflicts); + /** + * Get the current checkpoint ID. + * + * @callback {Function} callback Callback function called with `(err, currentCheckpointId)` arguments. Required. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Number} currentCheckpointId Current checkpoint ID. + */ + + PersistedModel.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] Callback function called with `(err, conflicts)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {Conflict[]} conflicts A list of changes that could not be replicated due to conflicts. + * @param {Object] checkpoints The new checkpoints to use as the "since" + * argument for the next replication. + */ + + PersistedModel.replicate = function(since, targetModel, options, callback) { + var lastArg = arguments[arguments.length - 1]; + + if (typeof lastArg === 'function' && arguments.length > 1) { + callback = lastArg; } - if (callback) { - var newCheckpoints = { source: newSourceCp, target: newTargetCp }; - callback(null, conflicts, newCheckpoints, updates); + if (typeof since === 'function' && since.modelName) { + targetModel = since; + since = -1; + } + + if (typeof since !== 'object') { + since = { source: since, target: since }; + } + + options = options || {}; + + var sourceModel = this; + callback = callback || function defaultReplicationCallback(err) { + if (err) throw err; + }; + + debug('replicating %s since %s to %s since %s', + sourceModel.modelName, + since.source, + targetModel.modelName, + since.target); + if (options.filter) { + debug('\twith filter %j', options.filter); + } + + // In order to avoid a race condition between the replication and + // other clients modifying the data, we must create the new target + // checkpoint as the first step of the replication process. + // As a side-effect of that, the replicated changes are associated + // with the new target checkpoint. This is actually desired behaviour, + // because that way clients replicating *from* the target model + // since the new checkpoint will pick these changes up. + // However, it increases the likelihood of (false) conflicts being detected. + // In order to prevent that, we run the replication multiple times, + // until no changes were replicated, but at most MAX_ATTEMPTS times + // to prevent starvation. In most cases, the second run will find no changes + // to replicate and we are done. + var MAX_ATTEMPTS = 3; + + run(1, since); + + function run(attempt, since) { + debug('\titeration #%s', attempt); + tryReplicate(sourceModel, targetModel, since, options, next); + + function next(err, conflicts, cps, updates) { + var finished = err || conflicts.length || + !updates || updates.length === 0 || + attempt >= MAX_ATTEMPTS; + + if (finished) + return callback(err, conflicts, cps); + run(attempt + 1, cps); + } + } + }; + + function tryReplicate(sourceModel, targetModel, since, options, callback) { + var Change = sourceModel.getChangeModel(); + var TargetChange = targetModel.getChangeModel(); + var changeTrackingEnabled = Change && TargetChange; + + assert( + changeTrackingEnabled, + 'You must enable change tracking before replicating' + ); + + var diff; + var updates; + var newSourceCp, newTargetCp; + + var tasks = [ + checkpoints, + getSourceChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate + ]; + + async.waterfall(tasks, done); + + function getSourceChanges(cb) { + sourceModel.changes(since.source, options.filter, debug.enabled ? log : cb); + + function log(err, result) { + if (err) return cb(err); + debug('\tusing source changes'); + result.forEach(function(it) { debug('\t\t%j', it); }); + cb(err, result); + } + } + + function getDiffFromTarget(sourceChanges, cb) { + targetModel.diff(since.target, sourceChanges, debug.enabled ? log : cb); + + function log(err, result) { + if (err) return cb(err); + if (result.conflicts && result.conflicts.length) { + debug('\tdiff conflicts'); + result.conflicts.forEach(function(d) { debug('\t\t%j', d); }); + } + if (result.deltas && result.deltas.length) { + debug('\tdiff deltas'); + result.deltas.forEach(function(it) { debug('\t\t%j', it); }); + } + cb(err, result); + } + } + + function createSourceUpdates(_diff, cb) { + diff = _diff; + diff.conflicts = diff.conflicts || []; + if (diff && diff.deltas && diff.deltas.length) { + debug('\tbuilding a list of updates'); + sourceModel.createUpdates(diff.deltas, cb); + } else { + // nothing to replicate + done(); + } + } + + function bulkUpdate(_updates, cb) { + debug('\tstarting bulk update'); + updates = _updates; + targetModel.bulkUpdate(updates, function(err) { + var conflicts = err && err.details && err.details.conflicts; + if (conflicts && err.statusCode == 409) { + diff.conflicts = conflicts; + // filter out updates that were not applied + updates = updates.filter(function(u) { + return conflicts + .filter(function(d) { return d.modelId === u.change.modelId; }) + .length === 0; + }); + return cb(); + } + cb(err); + }); + } + + function checkpoints() { + var cb = arguments[arguments.length - 1]; + sourceModel.checkpoint(function(err, source) { + if (err) return cb(err); + newSourceCp = source.seq; + targetModel.checkpoint(function(err, target) { + if (err) return cb(err); + newTargetCp = target.seq; + debug('\tcreated checkpoints'); + debug('\t\t%s for source model %s', newSourceCp, sourceModel.modelName); + debug('\t\t%s for target model %s', newTargetCp, targetModel.modelName); + cb(); + }); + }); + } + + function done(err) { + if (err) return callback(err); + + debug('\treplication finished'); + debug('\t\t%s conflict(s) detected', diff.conflicts.length); + debug('\t\t%s change(s) applied', updates ? updates.length : 0); + debug('\t\tnew checkpoints: { source: %j, target: %j }', + newSourceCp, newTargetCp); + + var conflicts = diff.conflicts.map(function(change) { + return new Change.Conflict( + change.modelId, sourceModel, targetModel + ); + }); + + if (conflicts.length) { + sourceModel.emit('conflicts', conflicts); + } + + if (callback) { + var newCheckpoints = { source: newSourceCp, target: newTargetCp }; + callback(null, conflicts, newCheckpoints, updates); + } } } -} -/** - * Create an update list (for `Model.bulkUpdate()`) from a delta list - * (result of `Change.diff()`). - * - * @param {Array} deltas - * @param {Function} callback - */ + /** + * Create an update list (for `Model.bulkUpdate()`) from a delta list + * (result of `Change.diff()`). + * + * @param {Array} deltas + * @param {Function} callback + */ -PersistedModel.createUpdates = function(deltas, cb) { - var Change = this.getChangeModel(); - var updates = []; - var Model = this; - var tasks = []; + PersistedModel.createUpdates = function(deltas, cb) { + var Change = this.getChangeModel(); + var updates = []; + var Model = this; + var tasks = []; - deltas.forEach(function(change) { - 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) { - 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 [createUpdates()](#persistedmodel-createupdates). - * @param {Function} callback Callback function. - */ - -PersistedModel.bulkUpdate = function(updates, callback) { - var tasks = []; - var Model = this; - var Change = this.getChangeModel(); - var conflicts = []; - - buildLookupOfAffectedModelData(Model, updates, function(err, currentMap) { - if (err) return callback(err); - - updates.forEach(function(update) { - var id = update.change.modelId; - var current = currentMap[id]; - switch (update.type) { + deltas.forEach(function(change) { + 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) { - applyUpdate(Model, id, current, update.data, update.change, conflicts, cb); - }); - break; - - case Change.CREATE: - tasks.push(function(cb) { - applyCreate(Model, id, current, update.data, update.change, conflicts, cb); + Model.findById(change.modelId, function(err, inst) { + if (err) return cb(err); + if (!inst) { + 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: - tasks.push(function(cb) { - applyDelete(Model, id, current, update.change, conflicts, cb); - }); + 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 [createUpdates()](#persistedmodel-createupdates). + * @param {Function} callback Callback function. + */ + + PersistedModel.bulkUpdate = function(updates, callback) { + var tasks = []; + var Model = this; + var Change = this.getChangeModel(); + var conflicts = []; + + buildLookupOfAffectedModelData(Model, updates, function(err, currentMap) { if (err) return callback(err); - if (conflicts.length) { - err = new Error('Conflict'); - err.statusCode = 409; - err.details = { conflicts: conflicts }; - return callback(err); - } - callback(); + + updates.forEach(function(update) { + var id = update.change.modelId; + var current = currentMap[id]; + switch (update.type) { + case Change.UPDATE: + tasks.push(function(cb) { + applyUpdate(Model, id, current, update.data, update.change, conflicts, cb); + }); + break; + + case Change.CREATE: + tasks.push(function(cb) { + applyCreate(Model, id, current, update.data, update.change, conflicts, cb); + }); + break; + case Change.DELETE: + tasks.push(function(cb) { + applyDelete(Model, id, current, update.change, conflicts, cb); + }); + break; + } + }); + + async.parallel(tasks, function(err) { + if (err) return callback(err); + if (conflicts.length) { + err = new Error('Conflict'); + err.statusCode = 409; + err.details = { conflicts: conflicts }; + return callback(err); + } + callback(); + }); }); - }); -}; + }; -function buildLookupOfAffectedModelData(Model, updates, callback) { - var idName = Model.dataSource.idName(Model.modelName); - var affectedIds = updates.map(function(u) { return u.change.modelId; }); - var whereAffected = {}; - whereAffected[idName] = { inq: affectedIds }; - Model.find({ where: whereAffected }, function(err, affectedList) { - if (err) return callback(err); - var dataLookup = {}; - affectedList.forEach(function(it) { - dataLookup[it[idName]] = it; + function buildLookupOfAffectedModelData(Model, updates, callback) { + var idName = Model.dataSource.idName(Model.modelName); + var affectedIds = updates.map(function(u) { return u.change.modelId; }); + var whereAffected = {}; + whereAffected[idName] = { inq: affectedIds }; + Model.find({ where: whereAffected }, function(err, affectedList) { + if (err) return callback(err); + var dataLookup = {}; + affectedList.forEach(function(it) { + dataLookup[it[idName]] = it; + }); + callback(null, dataLookup); }); - callback(null, dataLookup); - }); -} - -function applyUpdate(Model, id, current, data, change, conflicts, cb) { - var Change = Model.getChangeModel(); - var rev = current ? Change.revisionForInst(current) : null; - - if (rev !== change.prev) { - debug('Detected non-rectified change of %s %j', - Model.modelName, id); - debug('\tExpected revision: %s', change.rev); - debug('\tActual revision: %s', rev); - conflicts.push(change); - return Change.rectifyModelChanges(Model.modelName, [id], cb); } - // TODO(bajtos) modify `data` so that it instructs - // the connector to remove any properties included in "inst" - // but not included in `data` - // See https://github.com/strongloop/loopback/issues/1215 + function applyUpdate(Model, id, current, data, change, conflicts, cb) { + var Change = Model.getChangeModel(); + var rev = current ? Change.revisionForInst(current) : null; - Model.updateAll(current.toObject(), data, function(err, result) { - if (err) return cb(err); - - var count = result && result.count; - switch (count) { - case 1: - // The happy path, exactly one record was updated - return cb(); - - case 0: - debug('UpdateAll detected non-rectified change of %s %j', - Model.modelName, id); - conflicts.push(change); - // NOTE(bajtos) updateAll triggers change rectification - // for all model instances, even when no records were updated, - // thus we don't need to rectify explicitly ourselves - return cb(); - - case undefined: - case null: - return cb(new Error( - 'Cannot apply bulk updates, ' + - 'the connector does not correctly report ' + - 'the number of updated records.')); - - default: - debug('%s.updateAll modified unexpected number of instances: %j', - Model.modelName, count); - return cb(new Error( - 'Bulk update failed, the connector has modified unexpected ' + - 'number of records: ' + JSON.stringify(count))); + if (rev !== change.prev) { + debug('Detected non-rectified change of %s %j', + Model.modelName, id); + debug('\tExpected revision: %s', change.rev); + debug('\tActual revision: %s', rev); + conflicts.push(change); + return Change.rectifyModelChanges(Model.modelName, [id], cb); } - }); -} -function applyCreate(Model, id, current, data, change, conflicts, cb) { - Model.create(data, function(createErr) { - if (!createErr) return cb(); + // TODO(bajtos) modify `data` so that it instructs + // the connector to remove any properties included in "inst" + // but not included in `data` + // See https://github.com/strongloop/loopback/issues/1215 - // We don't have a reliable way how to detect the situation - // where he model was not create because of a duplicate id - // The workaround is to query the DB to check if the model already exists - Model.findById(id, function(findErr, inst) { - if (findErr || !inst) { - // There isn't any instance with the same id, thus there isn't - // any conflict and we just report back the original error. - return cb(createErr); + Model.updateAll(current.toObject(), data, function(err, result) { + if (err) return cb(err); + + var count = result && result.count; + switch (count) { + case 1: + // The happy path, exactly one record was updated + return cb(); + + case 0: + debug('UpdateAll detected non-rectified change of %s %j', + Model.modelName, id); + conflicts.push(change); + // NOTE(bajtos) updateAll triggers change rectification + // for all model instances, even when no records were updated, + // thus we don't need to rectify explicitly ourselves + return cb(); + + case undefined: + case null: + return cb(new Error( + 'Cannot apply bulk updates, ' + + 'the connector does not correctly report ' + + 'the number of updated records.')); + + default: + debug('%s.updateAll modified unexpected number of instances: %j', + Model.modelName, count); + return cb(new Error( + 'Bulk update failed, the connector has modified unexpected ' + + 'number of records: ' + JSON.stringify(count))); } - - return conflict(); }); - }); + } - function conflict() { - // The instance already exists - report a conflict - debug('Detected non-rectified new instance of %s %j', - Model.modelName, id); - conflicts.push(change); + function applyCreate(Model, id, current, data, change, conflicts, cb) { + Model.create(data, function(createErr) { + if (!createErr) return cb(); + + // We don't have a reliable way how to detect the situation + // where he model was not create because of a duplicate id + // The workaround is to query the DB to check if the model already exists + Model.findById(id, function(findErr, inst) { + if (findErr || !inst) { + // There isn't any instance with the same id, thus there isn't + // any conflict and we just report back the original error. + return cb(createErr); + } + + return conflict(); + }); + }); + + function conflict() { + // The instance already exists - report a conflict + debug('Detected non-rectified new instance of %s %j', + Model.modelName, id); + conflicts.push(change); + + var Change = Model.getChangeModel(); + return Change.rectifyModelChanges(Model.modelName, [id], cb); + } + } + + function applyDelete(Model, id, current, change, conflicts, cb) { + if (!current) { + // The instance was either already deleted or not created at all, + // we are done. + return cb(); + } var Change = Model.getChangeModel(); - return Change.rectifyModelChanges(Model.modelName, [id], cb); - } -} - -function applyDelete(Model, id, current, change, conflicts, cb) { - if (!current) { - // The instance was either already deleted or not created at all, - // we are done. - return cb(); - } - - var Change = Model.getChangeModel(); - var rev = Change.revisionForInst(current); - if (rev !== change.prev) { - debug('Detected non-rectified change of %s %j', - Model.modelName, id); - debug('\tExpected revision: %s', change.rev); - debug('\tActual revision: %s', rev); - conflicts.push(change); - return Change.rectifyModelChanges(Model.modelName, [id], cb); - } - - Model.deleteAll(current.toObject(), function(err, result) { - if (err) return cb(err); - - var count = result && result.count; - switch (count) { - case 1: - // The happy path, exactly one record was updated - return cb(); - - case 0: - debug('DeleteAll detected non-rectified change of %s %j', - Model.modelName, id); - conflicts.push(change); - // NOTE(bajtos) deleteAll triggers change rectification - // for all model instances, even when no records were updated, - // thus we don't need to rectify explicitly ourselves - return cb(); - - case undefined: - case null: - return cb(new Error( - 'Cannot apply bulk updates, ' + - 'the connector does not correctly report ' + - 'the number of deleted records.')); - - default: - debug('%s.deleteAll modified unexpected number of instances: %j', - Model.modelName, count); - return cb(new Error( - 'Bulk update failed, the connector has deleted unexpected ' + - 'number of records: ' + JSON.stringify(count))); + var rev = Change.revisionForInst(current); + if (rev !== change.prev) { + debug('Detected non-rectified change of %s %j', + Model.modelName, id); + debug('\tExpected revision: %s', change.rev); + debug('\tActual revision: %s', rev); + conflicts.push(change); + return Change.rectifyModelChanges(Model.modelName, [id], cb); } - }); -} -/** - * Get the `Change` model. - * Throws an error if the change model is not correctly setup. - * @return {Change} - */ + Model.deleteAll(current.toObject(), function(err, result) { + if (err) return cb(err); -PersistedModel.getChangeModel = function() { - var changeModel = this.Change; - var isSetup = changeModel && changeModel.dataSource; + var count = result && result.count; + switch (count) { + case 1: + // The happy path, exactly one record was updated + return cb(); - assert(isSetup, 'Cannot get a setup Change model'); + case 0: + debug('DeleteAll detected non-rectified change of %s %j', + Model.modelName, id); + conflicts.push(change); + // NOTE(bajtos) deleteAll triggers change rectification + // for all model instances, even when no records were updated, + // thus we don't need to rectify explicitly ourselves + return cb(); - return changeModel; -}; + case undefined: + case null: + return cb(new Error( + 'Cannot apply bulk updates, ' + + 'the connector does not correctly report ' + + 'the number of deleted records.')); -/** - * Get the source identifier for this model or dataSource. - * - * @callback {Function} callback Callback function called with `(err, id)` arguments. - * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - * @param {String} sourceId Source identifier for the model or dataSource. - */ - -PersistedModel.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. - */ - -PersistedModel.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'); - - var idName = this.getIdName(); - var idProp = this.definition.properties[idName]; - var idType = idProp && idProp.type; - var idDefn = idProp && idProp.defaultFn; - if (idType !== String || !(idDefn === 'uuid' || idDefn === 'guid')) { - deprecated('The model ' + this.modelName + ' is tracking changes, ' + - 'which requries a string id with GUID/UUID default value.'); - } - - Change.attachTo(this.dataSource); - Change.getCheckpointModel().attachTo(this.dataSource); - - Model.observe('after save', rectifyOnSave); - - Model.observe('after delete', rectifyOnDelete); - - if (runtime.isServer) { - // initial cleanup - cleanup(); - - // cleanup - setInterval(cleanup, cleanupInterval); - } - - function cleanup() { - Model.rectifyAllChanges(function(err) { - if (err) { - Model.handleChangeError(err, 'cleanup'); + default: + debug('%s.deleteAll modified unexpected number of instances: %j', + Model.modelName, count); + return cb(new Error( + 'Bulk update failed, the connector has deleted unexpected ' + + 'number of records: ' + JSON.stringify(count))); } }); } -}; -function rectifyOnSave(ctx, next) { - if (ctx.instance) { - ctx.Model.rectifyChange(ctx.instance.getId(), reportErrorAndNext); - } else { - ctx.Model.rectifyAllChanges(reportErrorAndNext); - } + /** + * Get the `Change` model. + * Throws an error if the change model is not correctly setup. + * @return {Change} + */ - function reportErrorAndNext(err) { - if (err) { - ctx.Model.handleChangeError(err, 'after save'); + PersistedModel.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 or dataSource. + * + * @callback {Function} callback Callback function called with `(err, id)` arguments. + * @param {Error} err Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + * @param {String} sourceId Source identifier for the model or dataSource. + */ + + PersistedModel.getSourceId = function(cb) { + var dataSource = this.dataSource; + if (!dataSource) { + this.once('dataSourceAttached', this.getSourceId.bind(this, cb)); } - next(); - } -} + assert( + dataSource.connector.name, + 'Model.getSourceId: cannot get id without dataSource.connector.name' + ); + var id = [dataSource.connector.name, this.modelName].join('-'); + cb(null, id); + }; -function rectifyOnDelete(ctx, next) { - var id = ctx.instance ? ctx.instance.getId() : - getIdFromWhereByModelId(ctx.Model, ctx.where); + /** + * Enable the tracking of changes made to the model. Usually for replication. + */ - if (id) { - ctx.Model.rectifyChange(id, reportErrorAndNext); - } else { - ctx.Model.rectifyAllChanges(reportErrorAndNext); - } + PersistedModel.enableChangeTracking = function() { + var Model = this; + var Change = this.Change || this._defineChangeModel(); + var cleanupInterval = Model.settings.changeCleanupInterval || 30000; - function reportErrorAndNext(err) { - if (err) { - ctx.Model.handleChangeError(err, 'after delete'); + assert(this.dataSource, 'Cannot enableChangeTracking(): ' + this.modelName + + ' is not attached to a dataSource'); + + var idName = this.getIdName(); + var idProp = this.definition.properties[idName]; + var idType = idProp && idProp.type; + var idDefn = idProp && idProp.defaultFn; + if (idType !== String || !(idDefn === 'uuid' || idDefn === 'guid')) { + deprecated('The model ' + this.modelName + ' is tracking changes, ' + + 'which requries a string id with GUID/UUID default value.'); } - next(); - } -} -function getIdFromWhereByModelId(Model, where) { - var whereKeys = Object.keys(where); - if (whereKeys.length != 1) return undefined; + Change.attachTo(this.dataSource); + Change.getCheckpointModel().attachTo(this.dataSource); - var idName = Model.getIdName(); - if (whereKeys[0] !== idName) return undefined; + Model.observe('after save', rectifyOnSave); - var id = where[idName]; - // TODO(bajtos) support object values that are not LB conditions - if (typeof id === 'string' || typeof id === 'number') { - return id; - } - return undefined; -} + Model.observe('after delete', rectifyOnDelete); -PersistedModel._defineChangeModel = function() { - var BaseChangeModel = registry.getModel('Change'); - assert(BaseChangeModel, - 'Change model must be defined before enabling change replication'); + if (runtime.isServer) { + // initial cleanup + cleanup(); - this.Change = BaseChangeModel.extend(this.modelName + '-change', - {}, - { - trackModel: this + // cleanup + setInterval(cleanup, cleanupInterval); } - ); - return this.Change; + function cleanup() { + Model.rectifyAllChanges(function(err) { + if (err) { + Model.handleChangeError(err, 'cleanup'); + } + }); + } + }; + + function rectifyOnSave(ctx, next) { + if (ctx.instance) { + ctx.Model.rectifyChange(ctx.instance.getId(), reportErrorAndNext); + } else { + ctx.Model.rectifyAllChanges(reportErrorAndNext); + } + + function reportErrorAndNext(err) { + if (err) { + ctx.Model.handleChangeError(err, 'after save'); + } + next(); + } + } + + function rectifyOnDelete(ctx, next) { + var id = ctx.instance ? ctx.instance.getId() : + getIdFromWhereByModelId(ctx.Model, ctx.where); + + if (id) { + ctx.Model.rectifyChange(id, reportErrorAndNext); + } else { + ctx.Model.rectifyAllChanges(reportErrorAndNext); + } + + function reportErrorAndNext(err) { + if (err) { + ctx.Model.handleChangeError(err, 'after delete'); + } + next(); + } + } + + function getIdFromWhereByModelId(Model, where) { + var whereKeys = Object.keys(where); + if (whereKeys.length != 1) return undefined; + + var idName = Model.getIdName(); + if (whereKeys[0] !== idName) return undefined; + + var id = where[idName]; + // TODO(bajtos) support object values that are not LB conditions + if (typeof id === 'string' || typeof id === 'number') { + return id; + } + return undefined; + } + + PersistedModel._defineChangeModel = function() { + var BaseChangeModel = registry.getModel('Change'); + assert(BaseChangeModel, + 'Change model must be defined before enabling change replication'); + + this.Change = BaseChangeModel.extend(this.modelName + '-change', + {}, + { + trackModel: this + } + ); + + return this.Change; + }; + + PersistedModel.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 Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). + */ + + PersistedModel.handleChangeError = function(err, operationName) { + if (!err) return; + this.emit('error', err, operationName); + }; + + /** + * Specify 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 + */ + + PersistedModel.rectifyChange = function(id, callback) { + var Change = this.getChangeModel(); + Change.rectifyModelChanges(this.modelName, [id], callback); + }; + + PersistedModel.setup(); + + return PersistedModel; }; - -PersistedModel.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 Error object; see [Error object](http://docs.strongloop.com/display/LB/Error+object). - */ - -PersistedModel.handleChangeError = function(err, operationName) { - if (!err) return; - this.emit('error', err, operationName); -}; - -/** - * Specify 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 - */ - -PersistedModel.rectifyChange = function(id, callback) { - var Change = this.getChangeModel(); - Change.rectifyModelChanges(this.modelName, [id], callback); -}; - -PersistedModel.setup(); diff --git a/lib/registry.js b/lib/registry.js index cf39879a..d9c5eb28 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -431,8 +431,8 @@ registry.DataSource = DataSource; * @private */ -registry.Model = require('./model'); -registry.PersistedModel = require('./persisted-model'); +registry.Model = require('./model')(registry); +registry.PersistedModel = require('./persisted-model')(registry); // temporary alias to simplify migration of code based on <=2.0.0-beta3 Object.defineProperty(registry, 'DataModel', {