diff --git a/.gitignore b/.gitignore index a84a7659..a30033bd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ *.swp *.swo node_modules +dist +*xunit.xml diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 00000000..d8551ddb --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,35 @@ +# Breaking Changes + +# 1.9 + +## Remote Method API + +`loopback.remoteMethod()` is now deprecated. + +Defining remote methods now should be done like this: + +```js +// static +MyModel.greet = function(msg, cb) { + cb(null, 'greetings... ' + msg); +} + +MyModel.remoteMethod( + 'greet', + { + accepts: [{arg: 'msg', type: 'string'}], + returns: {arg: 'greeting', type: 'string'} + } +); +``` + +**NOTE: remote instance method support is also now deprecated... +Use static methods instead. If you absolutely need it you can still set +`options.isStatic = false`** We plan to drop support for instance methods in +`2.0`. + +## Remote Instance Methods + +All remote instance methods have been replaced with static replacements. + +The REST API is backwards compatible. diff --git a/Gruntfile.js b/Gruntfile.js index cfd7b873..09aa93fe 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,6 +1,8 @@ /*global module:false*/ module.exports = function(grunt) { + grunt.loadNpmTasks('grunt-mocha-test'); + // Project configuration. grunt.initConfig({ // Metadata. @@ -53,86 +55,39 @@ module.exports = function(grunt) { } } }, - karma: { - unit: { + mochaTest: { + 'unit': { + src: 'test/*.js', options: { - // base path, that will be used to resolve files and exclude - basePath: '', - - // frameworks to use - frameworks: ['mocha', 'browserify'], - - // list of files / patterns to load in the browser - files: [ - 'test/support.js', - 'test/model.test.js', - 'test/geo-point.test.js' - ], - - // list of files to exclude - exclude: [ - - ], - - // test results reporter to use - // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' - reporters: ['dots'], - - // web server port - port: 9876, - - // cli runner port - runnerPort: 9100, - - // enable / disable colors in the output (reporters and logs) - colors: true, - - // level of logging - // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG - logLevel: 'warn', - - // enable / disable watching file and executing tests whenever any file changes - autoWatch: true, - - // Start these browsers, currently available: - // - Chrome - // - ChromeCanary - // - Firefox - // - Opera - // - Safari (only Mac) - // - PhantomJS - // - IE (only Windows) - browsers: [ - 'Chrome' - ], - - // If browser does not capture in given timeout [ms], kill it - captureTimeout: 60000, - - // Continuous Integration mode - // if true, it capture browsers, run tests and exit - singleRun: false, - - // Browserify config (all optional) - browserify: { - // extensions: ['.coffee'], - ignore: [ - 'nodemailer', - 'passport', - 'passport-local', - 'superagent', - 'supertest' - ], - // transform: ['coffeeify'], - // debug: true, - // noParse: ['jquery'], - watch: true, - }, - - // Add browserify to preprocessors - preprocessors: {'test/*': ['browserify']} + reporter: 'dot', } }, + 'unit-xml': { + src: 'test/*.js', + options: { + reporter: 'xunit', + captureFile: 'xunit.xml' + } + } + }, + karma: { + 'unit-once': { + configFile: 'test/karma.conf.js', + browsers: [ 'PhantomJS' ], + singleRun: true, + reporters: ['dots', 'junit'], + + // increase the timeout for slow build slaves (e.g. Travis-ci) + browserNoActivityTimeout: 30000, + + // CI friendly test output + junitReporter: { + outputFile: 'karma-xunit.xml' + }, + }, + unit: { + configFile: 'test/karma.conf.js', + }, e2e: { options: { // base path, that will be used to resolve files and exclude @@ -143,7 +98,8 @@ module.exports = function(grunt) { // list of files / patterns to load in the browser files: [ - 'test/e2e/remote-connector.e2e.js' + 'test/e2e/remote-connector.e2e.js', + 'test/e2e/replication.e2e.js' ], // list of files to exclude @@ -232,4 +188,10 @@ module.exports = function(grunt) { // Default task. grunt.registerTask('default', ['browserify']); + grunt.registerTask('test', [ + process.env.JENKINS_HOME ? 'mochaTest:unit-xml' : 'mochaTest:unit', + 'karma:unit-once']); + + // alias for sl-ci-run and `npm test` + grunt.registerTask('mocha-and-karma', ['test']); }; diff --git a/docs.json b/docs.json index 4517a139..c79bfa0d 100644 --- a/docs.json +++ b/docs.json @@ -5,9 +5,9 @@ "lib/loopback.js", "lib/runtime.js", "lib/registry.js", -{ "title": "Base model", "depth": 2 }, +{ "title": "Base models", "depth": 2 }, "lib/models/model.js", - "lib/models/data-model.js", + "lib/models/persisted-model.js", { "title": "Middleware", "depth": 2 }, "lib/middleware/rest.js", "lib/middleware/status.js", @@ -19,7 +19,8 @@ "lib/models/application.js", "lib/models/email.js", "lib/models/role.js", - "lib/models/user.js" + "lib/models/user.js", + "lib/models/change.js" ], "assets": "/docs/assets" } diff --git a/example/client-server/models.js b/example/client-server/models.js index c14485c8..34d5c8ba 100644 --- a/example/client-server/models.js +++ b/example/client-server/models.js @@ -1,6 +1,6 @@ var loopback = require('../../'); -var CartItem = exports.CartItem = loopback.DataModel.extend('CartItem', { +var CartItem = exports.CartItem = loopback.PersistedModel.extend('CartItem', { tax: {type: Number, default: 0.1}, price: Number, item: String, @@ -22,8 +22,7 @@ CartItem.sum = function(cartId, callback) { }); } -loopback.remoteMethod( - CartItem.sum, +CartItem.remoteMethod('sum', { accepts: {arg: 'cartId', type: 'number'}, returns: {arg: 'total', type: 'number'} diff --git a/example/replication/app.js b/example/replication/app.js new file mode 100644 index 00000000..ab6e6987 --- /dev/null +++ b/example/replication/app.js @@ -0,0 +1,138 @@ +var loopback = require('../../'); +var app = loopback(); +var db = app.dataSource('db', {connector: loopback.Memory}); +var Color = app.model('color', {dataSource: 'db', options: {trackChanges: true}}); +var Color2 = app.model('color2', {dataSource: 'db', options: {trackChanges: true}}); +var target = Color2; +var source = Color; +var SPEED = process.env.SPEED || 100; +var conflicts; + +var steps = [ + + createSomeInitialSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + updateSomeTargetData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data '), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + updateSomeSourceDataCausingAConflict, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (now has a conflict)'), + list.bind(this, target, 'current TARGET data (includes conflicting update)'), + + resolveAllConflicts, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (conflict resolved)'), + list.bind(this, target, 'current TARGET data (conflict resolved)'), + + createMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + createEvenMoreSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data'), + + deleteAllSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data (empty)'), + list.bind(this, target, 'current TARGET data (empty)'), + + createSomeNewSourceData, + + replicateSourceToTarget, + list.bind(this, source, 'current SOURCE data'), + list.bind(this, target, 'current TARGET data') +]; + +run(steps); + +function createSomeInitialSourceData() { + Color.create([ + {name: 'red'}, + {name: 'blue'}, + {name: 'green'} + ]); +} + +function replicateSourceToTarget() { + Color.replicate(0, Color2, {}, function(err, replicationConflicts) { + conflicts = replicationConflicts; + }); +} + +function resolveAllConflicts() { + if(conflicts.length) { + conflicts.forEach(function(conflict) { + conflict.resolve(); + }); + } +} + +function updateSomeTargetData() { + Color2.findById(1, function(err, color) { + color.name = 'conflict'; + color.save(); + }); +} + +function createMoreSourceData() { + Color.create({name: 'orange'}); +} + +function createEvenMoreSourceData() { + Color.create({name: 'black'}); +} + +function updateSomeSourceDataCausingAConflict() { + Color.findById(1, function(err, color) { + color.name = 'red!!!!'; + color.save(); + }); +} + +function deleteAllSourceData() { + Color.destroyAll(); +} + +function createSomeNewSourceData() { + Color.create([ + {name: 'violet'}, + {name: 'amber'}, + {name: 'olive'} + ]); +} + +function list(model, msg) { + console.log(msg); + model.find(function(err, items) { + items.forEach(function(item) { + console.log(' -', item.name); + }); + console.log(); + }); +} + +function run(steps) { + setInterval(function() { + var step = steps.shift(); + if(step) { + console.log(step.name); + step(); + } + }, SPEED); +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000..25695874 Binary files /dev/null and b/favicon.ico differ diff --git a/lib/application.js b/lib/application.js index 57ae348a..84884098 100644 --- a/lib/application.js +++ b/lib/application.js @@ -4,13 +4,11 @@ var DataSource = require('loopback-datasource-juggler').DataSource , registry = require('./registry') - , compat = require('./compat') , assert = require('assert') , fs = require('fs') , extend = require('util')._extend , _ = require('underscore') , RemoteObjects = require('strong-remoting') - , swagger = require('strong-remoting/ext/swagger') , stringUtils = require('underscore.string') , path = require('path'); @@ -94,7 +92,7 @@ app.disuse = function (route) { * var User = loopback.User; * app.model(User, { dataSource: 'db' }); * - * // The old way: create and attach a new model (deprecated) + * // LoopBack 1.x way: create and attach a new model (deprecated) * var Widget = app.model('Widget', { * dataSource: 'db', * properties: { @@ -152,13 +150,14 @@ app.model = function (Model, config) { this.models().push(Model); - if (isPublic) { - var remotingClassName = compat.getClassNameForRemoting(Model); - this.remotes().exports[remotingClassName] = Model; + if (isPublic && Model.sharedClass) { + this.remotes().addClass(Model.sharedClass); + if (Model.settings.trackChanges && Model.Change) + this.remotes().addClass(Model.Change.sharedClass); clearHandlerCache(this); } - Model.shared = isPublic; // The base Model has shared = true + Model.shared = isPublic; Model.app = this; Model.emit('attached', this); return Model; @@ -167,9 +166,6 @@ app.model = function (Model, config) { /** * Get the models exported by the app. Returns only models defined using `app.model()` * - * **Deprecated. Use the package - * [loopback-boot](https://github.com/strongloop/loopback-boot) instead.** - * There are two ways to access models: * * 1. Call `app.models()` to get a list of all models. @@ -261,47 +257,14 @@ app.connector = function(name, connector) { app.remoteObjects = function () { var result = {}; - var models = this.models(); - - // add in models - models.forEach(function (ModelCtor) { - // only add shared models - if(ModelCtor.shared && typeof ModelCtor.sharedCtor === 'function') { - result[compat.getClassNameForRemoting(ModelCtor)] = ModelCtor; - } + + this.remotes().classes().forEach(function(sharedClass) { + result[sharedClass.name] = sharedClass.ctor; }); return result; } -/** - * Enable swagger REST API documentation. - * - * **Note**: This method is deprecated. Use [loopback-explorer](http://npmjs.org/package/loopback-explorer) instead. - * - * **Options** - * - * - `basePath` The basepath for your API - eg. 'http://localhost:3000'. - * - * **Example** - * - * ```js - * // enable docs - * app.docs({basePath: 'http://localhost:3000'}); - * ``` - * - * Run your app then navigate to - * [the API explorer](http://petstore.swagger.wordnik.com/). - * Enter your API basepath to view your generated docs. - * - * @deprecated - */ - -app.docs = function (options) { - var remotes = this.remotes(); - swagger(remotes, options); -} - /*! * Get a handler of the specified type from the handler cache. */ @@ -381,207 +344,9 @@ app.enableAuth = function() { this.isAuthEnabled = true; }; -/** - * Initialize an application from an options object or a set of JSON and JavaScript files. - * - * This function takes an optional argument that is either a string or an object. - * - * If the argument is a string, then it sets the application root directory based on the string value. Then it: - * 1. Creates DataSources from the `datasources.json` file in the application root directory. - * 2. Creates Models from the `models.json` file in the application root directory. - * - * If the argument is an object, then it looks for `model`, `dataSources`, and `appRootDir` properties of the object. - * If the object has no `appRootDir` property then it sets the current working directory as the application root directory. - * Then it: - * 1. Creates DataSources from the `options.dataSources` object. - * 2. Creates Models from the `options.models` object. - * - * In both cases, the function loads JavaScript files in the `/models` and `/boot` subdirectories of the application root directory with `require()`. - * - * **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple - * files may result in models being **undefined** due to race conditions. - * To avoid this when using `app.boot()` make sure all models are passed as part of the `models` definition. - * - * Throws an error if the config object is not valid or if boot fails. - * - * - * **Model Definitions** - * - * The following is example JSON for two `Model` definitions: "dealership" and "location". - * - * ```js - * { - * "dealership": { - * // a reference, by name, to a dataSource definition - * "dataSource": "my-db", - * // the options passed to Model.extend(name, properties, options) - * "options": { - * "relations": { - * "cars": { - * "type": "hasMany", - * "model": "Car", - * "foreignKey": "dealerId" - * } - * } - * }, - * // the properties passed to Model.extend(name, properties, options) - * "properties": { - * "id": {"id": true}, - * "name": "String", - * "zip": "Number", - * "address": "String" - * } - * }, - * "car": { - * "dataSource": "my-db" - * "properties": { - * "id": { - * "type": "String", - * "required": true, - * "id": true - * }, - * "make": { - * "type": "String", - * "required": true - * }, - * "model": { - * "type": "String", - * "required": true - * } - * } - * } - * } - * ``` - * @options {String|Object} options Boot options; If String, this is the application root directory; if object, has below properties. - * @property {String} appRootDir Directory to use when loading JSON and JavaScript files (optional). Defaults to the current directory (`process.cwd()`). - * @property {Object} models Object containing `Model` definitions (optional). - * @property {Object} dataSources Object containing `DataSource` definitions (optional). - * @end - * - * @header app.boot([options]) - */ - app.boot = function(options) { - options = options || {}; - - if(typeof options === 'string') { - options = {appRootDir: options}; - } - var app = this; - var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); - var ctx = {}; - var appConfig = options.app; - var modelConfig = options.models; - var dataSourceConfig = options.dataSources; - - if(!appConfig) { - appConfig = tryReadConfig(appRootDir, 'app') || {}; - } - if(!modelConfig) { - modelConfig = tryReadConfig(appRootDir, 'models') || {}; - } - if(!dataSourceConfig) { - dataSourceConfig = tryReadConfig(appRootDir, 'datasources') || {}; - } - - assertIsValidConfig('app', appConfig); - assertIsValidConfig('model', modelConfig); - assertIsValidConfig('data source', dataSourceConfig); - - appConfig.host = - process.env.npm_config_host || - process.env.OPENSHIFT_SLS_IP || - process.env.OPENSHIFT_NODEJS_IP || - process.env.HOST || - appConfig.host || - process.env.npm_package_config_host || - app.get('host'); - - appConfig.port = _.find([ - process.env.npm_config_port, - process.env.OPENSHIFT_SLS_PORT, - process.env.OPENSHIFT_NODEJS_PORT, - process.env.PORT, - appConfig.port, - process.env.npm_package_config_port, - app.get('port'), - 3000 - ], _.isFinite); - - appConfig.restApiRoot = - appConfig.restApiRoot || - app.get('restApiRoot') || - '/api'; - - if(appConfig.host !== undefined) { - assert(typeof appConfig.host === 'string', 'app.host must be a string'); - app.set('host', appConfig.host); - } - - if(appConfig.port !== undefined) { - var portType = typeof appConfig.port; - assert(portType === 'string' || portType === 'number', 'app.port must be a string or number'); - app.set('port', appConfig.port); - } - - assert(appConfig.restApiRoot !== undefined, 'app.restApiRoot is required'); - assert(typeof appConfig.restApiRoot === 'string', 'app.restApiRoot must be a string'); - assert(/^\//.test(appConfig.restApiRoot), 'app.restApiRoot must start with "/"'); - app.set('restApiRoot', appConfig.restApiRoot); - - for(var configKey in appConfig) { - var cur = app.get(configKey); - if(cur === undefined || cur === null) { - app.set(configKey, appConfig[configKey]); - } - } - - // instantiate data sources - forEachKeyedObject(dataSourceConfig, function(key, obj) { - app.dataSource(key, obj); - }); - - // instantiate models - forEachKeyedObject(modelConfig, function(key, obj) { - app.model(key, obj); - }); - - // try to attach models to dataSources by type - try { - registry.autoAttach(); - } catch(e) { - if(e.name === 'AssertionError') { - console.warn(e); - } else { - throw e; - } - } - - // disable token requirement for swagger, if available - var swagger = app.remotes().exports.swagger; - var requireTokenForSwagger = appConfig.swagger - && appConfig.swagger.requireToken; - if(swagger) { - swagger.requireToken = requireTokenForSwagger || false; - } - - // require directories - var requiredModels = requireDir(path.join(appRootDir, 'models')); - var requiredBootScripts = requireDir(path.join(appRootDir, 'boot')); -} - -function assertIsValidConfig(name, config) { - if(config) { - assert(typeof config === 'object', name + ' config must be a valid JSON object'); - } -} - -function forEachKeyedObject(obj, fn) { - if(typeof obj !== 'object') return; - - Object.keys(obj).forEach(function(key) { - fn(key, obj[key]); - }); + throw new Error( + '`app.boot` was removed, use the new module loopback-boot instead'); } function classify(str) { @@ -634,216 +399,10 @@ function configureModel(ModelCtor, config, app) { registry.configureModel(ModelCtor, config); } -function requireDir(dir, basenames) { - assert(dir, 'cannot require directory contents without directory name'); - - var requires = {}; - - if (arguments.length === 2) { - // if basenames argument is passed, explicitly include those files - basenames.forEach(function (basename) { - var filepath = Path.resolve(Path.join(dir, basename)); - requires[basename] = tryRequire(filepath); - }); - } else if (arguments.length === 1) { - // if basenames arguments isn't passed, require all javascript - // files (except for those prefixed with _) and all directories - - var files = tryReadDir(dir); - - // sort files in lowercase alpha for linux - files.sort(function (a,b) { - a = a.toLowerCase(); - b = b.toLowerCase(); - - if (a < b) { - return -1; - } else if (b < a) { - return 1; - } else { - return 0; - } - }); - - files.forEach(function (filename) { - // ignore index.js and files prefixed with underscore - if ((filename === 'index.js') || (filename[0] === '_')) { return; } - - var filepath = path.resolve(path.join(dir, filename)); - var ext = path.extname(filename); - var stats = fs.statSync(filepath); - - // only require files supported by require.extensions (.txt .md etc.) - if (stats.isFile() && !(ext in require.extensions)) { return; } - - var basename = path.basename(filename, ext); - - requires[basename] = tryRequire(filepath); - }); - - } - - return requires; -}; - -function tryRequire(modulePath) { - try { - return require.apply(this, arguments); - } catch(e) { - console.error('failed to require "%s"', modulePath); - throw e; - } -} - -function tryReadDir() { - try { - return fs.readdirSync.apply(fs, arguments); - } catch(e) { - return []; - } -} - -function tryReadConfig(cwd, fileName) { - try { - return require(path.join(cwd, fileName + '.json')); - } catch(e) { - if(e.code !== "MODULE_NOT_FOUND") { - throw e; - } - } -} - function clearHandlerCache(app) { app._handlers = undefined; } -/*! - * This function is now deprecated. - * Install all express middleware required by LoopBack. - * - * It is possible to inject your own middleware by listening on one of the - * following events: - * - * - `middleware:preprocessors` is emitted after all other - * request-preprocessing middleware was installed, but before any - * request-handling middleware is configured. - * - * Usage: - * ```js - * app.once('middleware:preprocessors', function() { - * app.use(loopback.limit('5.5mb')) - * }); - * ``` - * - `middleware:handlers` is emitted when it's time to add your custom - * request-handling middleware. Note that you should not install any - * express routes at this point (express routes are discussed later). - * - * Usage: - * ```js - * app.once('middleware:handlers', function() { - * app.use('/admin', adminExpressApp); - * app.use('/custom', function(req, res, next) { - * res.send(200, { url: req.url }); - * }); - * }); - * ``` - * - `middleware:error-loggers` is emitted at the end, before the loopback - * error handling middleware is installed. This is the point where you - * can install your own middleware to log errors. - * - * Notes: - * - The middleware function must take four parameters, otherwise it won't - * be called by express. - * - * - It should also call `next(err)` to let the loopback error handler convert - * the error to an HTTP error response. - * - * Usage: - * ```js - * var bunyan = require('bunyan'); - * var log = bunyan.createLogger({name: "myapp"}); - * app.once('middleware:error-loggers', function() { - * app.use(function(err, req, res, next) { - * log.error(err); - * next(err); - * }); - * }); - * ``` - * - * Express routes should be added after `installMiddleware` was called. - * This way the express router middleware is injected at the right place in the - * middleware chain. If you add an express route before calling this function, - * bad things will happen: Express will automatically add the router - * middleware and since we haven't added request-preprocessing middleware like - * cookie & body parser yet, your route handlers will receive raw unprocessed - * requests. - * - * This is the correct order in which to call `app` methods: - * ```js - * app.boot(__dirname); // optional - * - * app.installMiddleware(); - * - * // [register your express routes here] - * - * app.listen(); - * ``` - */ -app.installMiddleware = function() { - var loopback = require('../'); - - /* - * Request pre-processing - */ - this.use(loopback.favicon()); - // TODO(bajtos) refactor to app.get('loggerFormat') - var loggerFormat = this.get('env') === 'development' ? 'dev' : 'default'; - this.use(loopback.logger(loggerFormat)); - this.use(loopback.cookieParser(this.get('cookieSecret'))); - this.use(loopback.token({ model: this.models.accessToken })); - this.use(loopback.bodyParser()); - this.use(loopback.methodOverride()); - - // Allow the app to install custom preprocessing middleware - this.emit('middleware:preprocessors'); - - /* - * Request handling - */ - - // LoopBack REST transport - this.use(this.get('restApiRoot') || '/api', loopback.rest()); - - // Allow the app to install custom request handling middleware - this.emit('middleware:handlers'); - - // Let express routes handle requests that were not handled - // by any of the middleware registered above. - // This way LoopBack REST and API Explorer take precedence over - // express routes. - this.use(this.router); - - // The static file server should come after all other routes - // Every request that goes through the static middleware hits - // the file system to check if a file exists. - this.use(loopback.static(path.join(__dirname, 'public'))); - - // Requests that get this far won't be handled - // by any middleware. Convert them into a 404 error - // that will be handled later down the chain. - this.use(loopback.urlNotFound()); - - /* - * Error handling - */ - - // Allow the app to install custom error logging middleware - this.emit('middleware:error-handlers'); - - // The ultimate error handler. - this.use(loopback.errorHandler()); -}; - /** * Listen for connections and update the configured port. * diff --git a/lib/compat.js b/lib/compat.js deleted file mode 100644 index 9fe324f7..00000000 --- a/lib/compat.js +++ /dev/null @@ -1,56 +0,0 @@ -var assert = require('assert'); - -/** - * Compatibility layer allowing applications based on an older LoopBack version - * to work with newer versions with minimum changes involved. - * - * You should not use it unless migrating from an older version of LoopBack. - */ - -var compat = exports; - -/** - * LoopBack versions pre-1.6 use plural model names when registering shared - * classes with strong-remoting. As the result, strong-remoting use method names - * like `Users.create` for the javascript methods like `User.create`. - * This has been fixed in v1.6, LoopBack consistently uses the singular - * form now. - * - * Turn this option on to enable the old behaviour. - * - * - `app.remotes()` and `app.remoteObjects()` will be indexed using - * plural names (Users instead of User). - * - * - Remote hooks must use plural names for the class name, i.e - * `Users.create` instead of `User.create`. This is transparently - * handled by `Model.beforeRemote()` and `Model.afterRemote()`. - * - * @type {boolean} - * @deprecated Your application should not depend on the way how loopback models - * and strong-remoting are wired together. It if does, you should update - * it to use singular model names. - */ - -compat.usePluralNamesForRemoting = false; - -/** - * Get the class name to use with strong-remoting. - * @param {function} Ctor Model class (constructor), e.g. `User` - * @return {string} Singular or plural name, depending on the value - * of `compat.usePluralNamesForRemoting` - * @internal - */ - -compat.getClassNameForRemoting = function(Ctor) { - assert( - typeof(Ctor) === 'function', - 'compat.getClassNameForRemoting expects a constructor as the argument'); - - if (compat.usePluralNamesForRemoting) { - assert(Ctor.pluralModelName, - 'Model must have a "pluralModelName" property in compat mode'); - return Ctor.pluralModelName; - } - - return Ctor.modelName; -}; diff --git a/lib/connectors/base-connector.js b/lib/connectors/base-connector.js index 763082ec..75ee55a2 100644 --- a/lib/connectors/base-connector.js +++ b/lib/connectors/base-connector.js @@ -51,4 +51,4 @@ Connector._createJDBAdapter = function (jdbModule) { Connector.prototype._addCrudOperationsFromJDBAdapter = function (connector) { -} \ No newline at end of file +} diff --git a/lib/connectors/mail.js b/lib/connectors/mail.js index cd9df7b8..1e9b346a 100644 --- a/lib/connectors/mail.js +++ b/lib/connectors/mail.js @@ -58,7 +58,12 @@ MailConnector.prototype.setupTransport = function(setting) { var connector = this; connector.transports = connector.transports || []; connector.transportsIndex = connector.transportsIndex || {}; - var transport = mailer.createTransport(setting.type, setting); + + var transportModuleName = 'nodemailer-' + (setting.type || 'STUB').toLowerCase() + '-transport'; + var transportModule = require(transportModuleName); + + var transport = mailer.createTransport(transportModule(setting)); + connector.transportsIndex[setting.type] = transport; connector.transports.push(transport); } diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 0b92e0c6..08f2a2f6 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -36,4 +36,4 @@ inherits(Memory, Connector); * JugglingDB Compatibility */ -Memory.initialize = JdbMemory.initialize; \ No newline at end of file +Memory.initialize = JdbMemory.initialize; diff --git a/lib/connectors/remote.js b/lib/connectors/remote.js index 065682f6..26beb4a5 100644 --- a/lib/connectors/remote.js +++ b/lib/connectors/remote.js @@ -2,9 +2,9 @@ * Dependencies. */ -var assert = require('assert') - , compat = require('../compat') - , _ = require('underscore'); +var assert = require('assert'); +var remoting = require('strong-remoting'); +var DataAccessObject = require('loopback-datasource-juggler/lib/dao'); /** * Export the RemoteConnector class. @@ -24,6 +24,10 @@ function RemoteConnector(settings) { this.root = settings.root || ''; this.host = settings.host || 'localhost'; this.port = settings.port || 3000; + this.remotes = remoting.create(); + + // TODO(ritch) make sure this name works with Model.getSourceId() + this.name = 'remote-connector'; if(settings.url) { this.url = settings.url; @@ -31,14 +35,14 @@ function RemoteConnector(settings) { this.url = this.protocol + '://' + this.host + ':' + this.port + this.root; } - // handle mixins here - this.DataAccessObject = function() {}; + // handle mixins in the define() method + var DAO = this.DataAccessObject = function() {}; } RemoteConnector.prototype.connect = function() { + this.remotes.connect(this.url, this.adapter); } - RemoteConnector.initialize = function(dataSource, callback) { var connector = dataSource.connector = new RemoteConnector(dataSource.settings); connector.connect(); @@ -47,22 +51,40 @@ RemoteConnector.initialize = function(dataSource, callback) { RemoteConnector.prototype.define = function(definition) { var Model = definition.model; - var className = compat.getClassNameForRemoting(Model); - var url = this.url; - var adapter = this.adapter; + var remotes = this.remotes; + var SharedClass; - Model.remotes(function(err, remotes) { - var sharedClass = getSharedClass(remotes, className); - remotes.connect(url, adapter); - sharedClass - .methods() - .forEach(Model.createProxyMethod.bind(Model)); - }); + assert(Model.sharedClass, 'cannot attach ' + Model.modelName + + ' to a remote connector without a Model.sharedClass'); + + remotes.addClass(Model.sharedClass); + + Model + .sharedClass + .methods() + .forEach(function(remoteMethod) { + // TODO(ritch) more elegant way of ignoring a nested shared class + if(remoteMethod.name !== 'Change' + && remoteMethod.name !== 'Checkpoint') { + createProxyMethod(Model, remotes, remoteMethod); + } + }); } -function getSharedClass(remotes, className) { - return _.find(remotes.classes(), function(sharedClass) { - return sharedClass.name === className; - }); +function createProxyMethod(Model, remotes, remoteMethod) { + var scope = remoteMethod.isStatic ? Model : Model.prototype; + var original = scope[remoteMethod.name]; + + scope[remoteMethod.name] = function remoteMethodProxy() { + var args = Array.prototype.slice.call(arguments); + var lastArgIsFunc = typeof args[args.length - 1] === 'function'; + var callback; + if(lastArgIsFunc) { + callback = args.pop(); + } + + remotes.invoke(remoteMethod.stringName, args, callback); + } } + function noop() {} diff --git a/lib/express-middleware.js b/lib/express-middleware.js new file mode 100644 index 00000000..bc316769 --- /dev/null +++ b/lib/express-middleware.js @@ -0,0 +1,53 @@ +var express = require('express'); +var path = require('path'); + +var middlewares = exports; + +function safeRequire(m) { + try { + return require(m); + } catch (err) { + return undefined; + } +} + +function createMiddlewareNotInstalled(memberName, moduleName) { + return function () { + var msg = 'The middleware loopback.' + memberName + ' is not installed.\n' + + 'Run `npm install --save ' + moduleName + '` to fix the problem.'; + throw new Error(msg); + }; +} + +var middlewareModules = { + "compress": "compression", + "timeout": "connect-timeout", + "cookieParser": "cookie-parser", + "cookieSession": "cookie-session", + "csrf": "csurf", + "errorHandler": "errorhandler", + "session": "express-session", + "methodOverride": "method-override", + "logger": "morgan", + "responseTime": "response-time", + "favicon": "serve-favicon", + "directory": "serve-index", + // "static": "serve-static", + "vhost": "vhost" +}; + +middlewares.bodyParser = safeRequire('body-parser'); +middlewares.json = middlewares.bodyParser && middlewares.bodyParser.json; +middlewares.urlencoded = middlewares.bodyParser && middlewares.bodyParser.urlencoded; + +for (var m in middlewareModules) { + var moduleName = middlewareModules[m]; + middlewares[m] = safeRequire(moduleName) || createMiddlewareNotInstalled(m, moduleName); +} + +// serve-favicon requires a path +var favicon = middlewares.favicon; +middlewares.favicon = function (icon, options) { + icon = icon || path.join(__dirname, '../favicon.ico'); + return favicon(icon, options); +}; diff --git a/lib/loopback.js b/lib/loopback.js index cdc9d296..1b8168f7 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -6,11 +6,7 @@ var express = require('express') , proto = require('./application') , fs = require('fs') , ejs = require('ejs') - , EventEmitter = require('events').EventEmitter , path = require('path') - , DataSource = require('loopback-datasource-juggler').DataSource - , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder - , i8n = require('inflection') , merge = require('util')._extend , assert = require('assert'); @@ -42,11 +38,6 @@ loopback.version = require('../package.json').version; loopback.mime = express.mime; -/*! - * Compatibility layer, intentionally left undocumented. - */ -loopback.compat = require('./compat'); - /*! * Create an loopback application. * @@ -84,6 +75,10 @@ function createApplication() { function mixin(source) { for (var key in source) { var desc = Object.getOwnPropertyDescriptor(source, key); + + // Fix for legacy (pre-ES5) browsers like PhantomJS + if (!desc) continue; + Object.defineProperty(loopback, key, desc); } } @@ -92,12 +87,22 @@ mixin(require('./runtime')); mixin(require('./registry')); /*! - * Expose express.middleware as loopback.* - * for example `loopback.errorHandler` etc. + * Expose static express methods like `express.errorHandler`. */ mixin(express); +/*! + * Expose additional middleware like session as loopback.* + * This will keep the loopback API compatible with express 3.x + * + * ***only in node*** + */ + +if (loopback.isServer) { + var middlewares = require('./express-middleware'); + mixin(middlewares); +} /*! * Expose additional loopback middleware @@ -168,6 +173,7 @@ loopback.Role = require('./models/role').Role; loopback.RoleMapping = require('./models/role').RoleMapping; loopback.ACL = require('./models/acl').ACL; loopback.Scope = require('./models/acl').Scope; +loopback.Change = require('./models/change'); /*! * Automatically attach these models to dataSources @@ -179,7 +185,7 @@ var dataSourceTypes = { }; loopback.Email.autoAttach = dataSourceTypes.MAIL; -loopback.DataModel.autoAttach = dataSourceTypes.DB; +loopback.PersistedModel.autoAttach = dataSourceTypes.DB; loopback.User.autoAttach = dataSourceTypes.DB; loopback.AccessToken.autoAttach = dataSourceTypes.DB; loopback.Role.autoAttach = dataSourceTypes.DB; diff --git a/lib/models/acl.js b/lib/models/acl.js index a10cfbbc..2c0abe57 100644 --- a/lib/models/acl.js +++ b/lib/models/acl.js @@ -92,7 +92,7 @@ var ACLSchema = { * @inherits Model */ -var ACL = loopback.createModel('ACL', ACLSchema); +var ACL = loopback.PersistedModel.extend('ACL', ACLSchema); ACL.ALL = AccessContext.ALL; @@ -261,7 +261,7 @@ ACL.resolvePermission = function resolvePermission(acls, req) { * @return {Object[]} An array of ACLs */ ACL.getStaticACLs = function getStaticACLs(model, property) { - var modelClass = loopback.getModel(model); + var modelClass = loopback.findModel(model); var staticACLs = []; if (modelClass && modelClass.settings.acls) { modelClass.settings.acls.forEach(function (acl) { @@ -343,7 +343,7 @@ ACL.checkPermission = function checkPermission(principalType, principalId, acls = acls.concat(dynACLs); resolved = self.resolvePermission(acls, req); if(resolved && resolved.permission === ACL.DEFAULT) { - var modelClass = loopback.getModel(model); + var modelClass = loopback.findModel(model); resolved.permission = (modelClass && modelClass.settings.defaultPermission) || ACL.ALLOW; } callback && callback(null, resolved); diff --git a/lib/models/application.js b/lib/models/application.js index b562d278..f395b6a1 100644 --- a/lib/models/application.js +++ b/lib/models/application.js @@ -113,7 +113,7 @@ function generateKey(hmacKey, algorithm, encoding) { * @inherits {Model} */ -var Application = loopback.createModel('Application', ApplicationSchema); +var Application = loopback.PersistedModel.extend('Application', ApplicationSchema); /*! * A hook to generate keys before creation diff --git a/lib/models/change.js b/lib/models/change.js new file mode 100644 index 00000000..9f291fdd --- /dev/null +++ b/lib/models/change.js @@ -0,0 +1,646 @@ +/** + * Module Dependencies. + */ + +var PersistedModel = require('./persisted-model') + , loopback = require('../loopback') + , crypto = require('crypto') + , CJSON = {stringify: require('canonical-json')} + , async = require('async') + , assert = require('assert') + , debug = require('debug')('loopback:change'); + +/** + * Properties + */ + +var properties = { + id: {type: String, generated: true, id: true}, + rev: {type: String}, + prev: {type: String}, + checkpoint: {type: Number}, + modelName: {type: String}, + modelId: {type: String} +}; + +/** + * Options + */ + +var options = { + trackChanges: false +}; + +/** + * Change list entry. + * + * @property id {String} Hash of the modelName and id + * @property rev {String} the current model revision + * @property prev {String} the previous model revision + * @property checkpoint {Number} the current checkpoint at time of the change + * @property modelName {String} the model name + * @property modelId {String} the model id + * + * @class + * @inherits {Model} + */ + +var Change = module.exports = PersistedModel.extend('Change', properties, options); + +/*! + * Constants + */ + +Change.UPDATE = 'update'; +Change.CREATE = 'create'; +Change.DELETE = 'delete'; +Change.UNKNOWN = 'unknown'; + +/*! + * Conflict Class + */ + +Change.Conflict = Conflict; + +/*! + * Setup the extended model. + */ + +Change.setup = function() { + PersistedModel.setup.call(this); + var Change = this; + + Change.getter.id = function() { + var hasModel = this.modelName && this.modelId; + if(!hasModel) return null; + + return Change.idForModel(this.modelName, this.modelId); + } +} +Change.setup(); + +/** + * Track the recent change of the given modelIds. + * + * @param {String} modelName + * @param {Array} modelIds + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes Changes that were tracked + */ + +Change.rectifyModelChanges = function(modelName, modelIds, callback) { + var tasks = []; + var Change = this; + + modelIds.forEach(function(id) { + tasks.push(function(cb) { + Change.findOrCreateChange(modelName, id, function(err, change) { + if(err) return Change.handleError(err, cb); + change.rectify(cb); + }); + }); + }); + async.parallel(tasks, callback); +} + +/** + * Get an identifier for a given model. + * + * @param {String} modelName + * @param {String} modelId + * @return {String} + */ + +Change.idForModel = function(modelName, modelId) { + return this.hash([modelName, modelId].join('-')); +} + +/** + * Find or create a change for the given model. + * + * @param {String} modelName + * @param {String} modelId + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + * @end + */ + +Change.findOrCreateChange = function(modelName, modelId, callback) { + assert(loopback.findModel(modelName), modelName + ' does not exist'); + var id = this.idForModel(modelName, modelId); + var Change = this; + + this.findById(id, function(err, change) { + if(err) return callback(err); + if(change) { + callback(null, change); + } else { + var ch = new Change({ + id: id, + modelName: modelName, + modelId: modelId + }); + ch.debug('creating change'); + ch.save(callback); + } + }); +} + +/** + * Update (or create) the change with the current revision. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} change + */ + +Change.prototype.rectify = function(cb) { + var change = this; + var tasks = [ + updateRevision, + updateCheckpoint + ]; + var currentRev = this.rev; + + change.debug('rectify change'); + + cb = cb || function(err) { + if(err) throw new Error(err); + } + + async.parallel(tasks, function(err) { + if(err) return cb(err); + if(change.prev === Change.UNKNOWN) { + // this occurs when a record of a change doesn't exist + // and its current revision is null (not found) + change.remove(cb); + } else { + change.save(cb); + } + }); + + function updateRevision(cb) { + // get the current revision + change.currentRevision(function(err, rev) { + if(err) return Change.handleError(err, cb); + if(rev) { + // avoid setting rev and prev to the same value + if(currentRev !== rev) { + change.rev = rev; + change.prev = currentRev; + } else { + change.debug('rev and prev are equal (not updating rev)'); + } + } else { + change.rev = null; + if(currentRev) { + change.prev = currentRev; + } else if(!change.prev) { + change.debug('ERROR - could not determing prev'); + change.prev = Change.UNKNOWN; + } + } + change.debug('updated revision (was ' + currentRev + ')'); + cb(); + }); + } + + function updateCheckpoint(cb) { + change.constructor.getCheckpointModel().current(function(err, checkpoint) { + if(err) return Change.handleError(err); + change.checkpoint = checkpoint; + cb(); + }); + } +} + +/** + * Get a change's current revision based on current data. + * @callback {Function} callback + * @param {Error} err + * @param {String} rev The current revision + */ + +Change.prototype.currentRevision = function(cb) { + var model = this.getModelCtor(); + var id = this.getModelId(); + model.findById(id, function(err, inst) { + if(err) return Change.handleError(err, cb); + if(inst) { + cb(null, Change.revisionForInst(inst)); + } else { + cb(null, null); + } + }); +} + +/** + * Create a hash of the given `string` with the `options.hashAlgorithm`. + * **Default: `sha1`** + * + * @param {String} str The string to be hashed + * @return {String} The hashed string + */ + +Change.hash = function(str) { + return crypto + .createHash(Change.settings.hashAlgorithm || 'sha1') + .update(str) + .digest('hex'); +} + +/** + * Get the revision string for the given object + * @param {Object} inst The data to get the revision string for + * @return {String} The revision string + */ + +Change.revisionForInst = function(inst) { + return this.hash(CJSON.stringify(inst)); +} + +/** + * Get a change's type. Returns one of: + * + * - `Change.UPDATE` + * - `Change.CREATE` + * - `Change.DELETE` + * - `Change.UNKNOWN` + * + * @return {String} the type of change + */ + +Change.prototype.type = function() { + if(this.rev && this.prev) { + return Change.UPDATE; + } + if(this.rev && !this.prev) { + return Change.CREATE; + } + if(!this.rev && this.prev) { + return Change.DELETE; + } + return Change.UNKNOWN; +} + +/** + * Compare two changes. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.equals = function(change) { + if(!change) return false; + var thisRev = this.rev || null; + var thatRev = change.rev || null; + return thisRev === thatRev; +} + +/** + * Does this change conflict with the given change. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.conflictsWith = function(change) { + if(!change) return false; + if(this.equals(change)) return false; + if(Change.bothDeleted(this, change)) return false; + if(this.isBasedOn(change)) return false; + return true; +} + +/** + * Are both changes deletes? + * @param {Change} a + * @param {Change} b + * @return {Boolean} + */ + +Change.bothDeleted = function(a, b) { + return a.type() === Change.DELETE + && b.type() === Change.DELETE; +} + +/** + * Determine if the change is based on the given change. + * @param {Change} change + * @return {Boolean} + */ + +Change.prototype.isBasedOn = function(change) { + return this.prev === change.rev; +} + +/** + * Determine the differences for a given model since a given checkpoint. + * + * The callback will contain an error or `result`. + * + * **result** + * + * ```js + * { + * deltas: Array, + * conflicts: Array + * } + * ``` + * + * **deltas** + * + * An array of changes that differ from `remoteChanges`. + * + * **conflicts** + * + * An array of changes that conflict with `remoteChanges`. + * + * @param {String} modelName + * @param {Number} since Compare changes after this checkpoint + * @param {Change[]} remoteChanges A set of changes to compare + * @callback {Function} callback + * @param {Error} err + * @param {Object} result See above. + */ + +Change.diff = function(modelName, since, remoteChanges, callback) { + var remoteChangeIndex = {}; + var modelIds = []; + remoteChanges.forEach(function(ch) { + modelIds.push(ch.modelId); + remoteChangeIndex[ch.modelId] = new Change(ch); + }); + + // normalize `since` + since = Number(since) || 0; + this.find({ + where: { + modelName: modelName, + modelId: {inq: modelIds}, + checkpoint: {gte: since} + } + }, function(err, localChanges) { + if(err) return callback(err); + var deltas = []; + var conflicts = []; + var localModelIds = []; + + localChanges.forEach(function(localChange) { + localChange = new Change(localChange); + localModelIds.push(localChange.modelId); + var remoteChange = remoteChangeIndex[localChange.modelId]; + if(remoteChange && !localChange.equals(remoteChange)) { + if(remoteChange.conflictsWith(localChange)) { + remoteChange.debug('remote conflict'); + localChange.debug('local conflict'); + conflicts.push(localChange); + } else { + remoteChange.debug('remote delta'); + deltas.push(remoteChange); + } + } + }); + + modelIds.forEach(function(id) { + if(localModelIds.indexOf(id) === -1) { + deltas.push(remoteChangeIndex[id]); + } + }); + + callback(null, { + deltas: deltas, + conflicts: conflicts + }); + }); +} + +/** + * Correct all change list entries. + * @param {Function} callback + */ + +Change.rectifyAll = function(cb) { + debug('rectify all'); + var Change = this; + // this should be optimized + this.find(function(err, changes) { + if(err) return cb(err); + changes.forEach(function(change) { + change = new Change(change); + change.rectify(); + }); + }); +} + +/** + * Get the checkpoint model. + * @return {Checkpoint} + */ + +Change.getCheckpointModel = function() { + var checkpointModel = this.Checkpoint; + if(checkpointModel) return checkpointModel; + this.checkpoint = checkpointModel = require('./checkpoint').extend('checkpoint'); + assert(this.dataSource, 'Cannot getCheckpointModel(): ' + this.modelName + + ' is not attached to a dataSource'); + checkpointModel.attachTo(this.dataSource); + return checkpointModel; +} + +Change.handleError = function(err) { + if(!this.settings.ignoreErrors) { + throw err; + } +} + +Change.prototype.debug = function() { + if(debug.enabled) { + var args = Array.prototype.slice.call(arguments); + debug.apply(this, args); + debug('\tid', this.id); + debug('\trev', this.rev); + debug('\tprev', this.prev); + debug('\tmodelName', this.modelName); + debug('\tmodelId', this.modelId); + debug('\ttype', this.type()); + } +} + +/** + * Get the `Model` class for `change.modelName`. + * @return {Model} + */ + +Change.prototype.getModelCtor = function() { + return this.constructor.settings.trackModel; +} + +Change.prototype.getModelId = function() { + // TODO(ritch) get rid of the need to create an instance + var Model = this.getModelCtor(); + var id = this.modelId; + var m = new Model(); + m.setId(id); + return m.getId(); +} + +Change.prototype.getModel = function(callback) { + var Model = this.constructor.settings.trackModel; + var id = this.getModelId(); + Model.findById(id, callback); +} + +/** + * When two changes conflict a conflict is created. + * + * **Note: call `conflict.fetch()` to get the `target` and `source` models. + * + * @param {*} modelId + * @param {PersistedModel} SourceModel + * @param {PersistedModel} TargetModel + * @property {ModelClass} source The source model instance + * @property {ModelClass} target The target model instance + */ + +function Conflict(modelId, SourceModel, TargetModel) { + this.SourceModel = SourceModel; + this.TargetModel = TargetModel; + this.SourceChange = SourceModel.getChangeModel(); + this.TargetChange = TargetModel.getChangeModel(); + this.modelId = modelId; +} + +/** + * Fetch the conflicting models. + * + * @callback {Function} callback + * @param {Error} + * @param {PersistedModel} source + * @param {PersistedModel} target + */ + +Conflict.prototype.models = function(cb) { + var conflict = this; + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var source; + var target; + + async.parallel([ + getSourceModel, + getTargetModel + ], done); + + function getSourceModel(cb) { + SourceModel.findById(conflict.modelId, function(err, model) { + if(err) return cb(err); + source = model; + cb(); + }); + } + + function getTargetModel(cb) { + TargetModel.findById(conflict.modelId, function(err, model) { + if(err) return cb(err); + target = model; + cb(); + }); + } + + function done(err) { + if(err) return cb(err); + cb(null, source, target); + } +} + +/** + * Get the conflicting changes. + * + * @callback {Function} callback + * @param {Error} err + * @param {Change} sourceChange + * @param {Change} targetChange + */ + +Conflict.prototype.changes = function(cb) { + var conflict = this; + var sourceChange; + var targetChange; + + async.parallel([ + getSourceChange, + getTargetChange + ], done); + + function getSourceChange(cb) { + conflict.SourceChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { + if(err) return cb(err); + sourceChange = change; + cb(); + }); + } + + function getTargetChange(cb) { + conflict.TargetChange.findOne({where: { + modelId: conflict.modelId + }}, function(err, change) { + if(err) return cb(err); + targetChange = change; + cb(); + }); + } + + function done(err) { + if(err) return cb(err); + cb(null, sourceChange, targetChange); + } +} + +/** + * Resolve the conflict. + * + * @callback {Function} callback + * @param {Error} err + */ + +Conflict.prototype.resolve = function(cb) { + var conflict = this; + conflict.changes(function(err, sourceChange, targetChange) { + if(err) return cb(err); + sourceChange.prev = targetChange.rev; + sourceChange.save(cb); + }); +} + +/** + * Determine the conflict type. + * + * ```js + * // possible results are + * Change.UPDATE // => source and target models were updated + * Change.DELETE // => the source and or target model was deleted + * Change.UNKNOWN // => the conflict type is uknown or due to an error + * ``` + * @callback {Function} callback + * @param {Error} err + * @param {String} type The conflict type. + */ + +Conflict.prototype.type = function(cb) { + var conflict = this; + this.changes(function(err, sourceChange, targetChange) { + if(err) return cb(err); + var sourceChangeType = sourceChange.type(); + var targetChangeType = targetChange.type(); + if(sourceChangeType === Change.UPDATE && targetChangeType === Change.UPDATE) { + return cb(null, Change.UPDATE); + } + if(sourceChangeType === Change.DELETE || targetChangeType === Change.DELETE) { + return cb(null, Change.DELETE); + } + return cb(null, Change.UNKNOWN); + }); +} diff --git a/lib/models/checkpoint.js b/lib/models/checkpoint.js new file mode 100644 index 00000000..2d607bf0 --- /dev/null +++ b/lib/models/checkpoint.js @@ -0,0 +1,77 @@ +/** + * Module Dependencies. + */ + +var PersistedModel = require('../loopback').PersistedModel + , loopback = require('../loopback') + , assert = require('assert'); + +/** + * Properties + */ + +var properties = { + seq: {type: Number}, + time: {type: Date, default: Date}, + sourceId: {type: String} +}; + +/** + * Options + */ + +var options = { + +}; + +/** + * Checkpoint list entry. + * + * @property id {Number} the sequencial identifier of a checkpoint + * @property time {Number} the time when the checkpoint was created + * @property sourceId {String} the source identifier + * + * @class + * @inherits {PersistedModel} + */ + +var Checkpoint = module.exports = PersistedModel.extend('Checkpoint', properties, options); + +/** + * Get the current checkpoint id + * @callback {Function} callback + * @param {Error} err + * @param {Number} checkpointId The current checkpoint id + */ + +Checkpoint.current = function(cb) { + var Checkpoint = this; + this.find({ + limit: 1, + order: 'seq DESC' + }, function(err, checkpoints) { + if(err) return cb(err); + var checkpoint = checkpoints[0]; + if(checkpoint) { + cb(null, checkpoint.seq); + } else { + Checkpoint.create({seq: 0}, function(err, checkpoint) { + if(err) return cb(err); + cb(null, checkpoint.seq); + }); + } + }); +} + +Checkpoint.beforeSave = function(next, model) { + if(!model.getId() && model.seq === undefined) { + model.constructor.current(function(err, seq) { + if(err) return next(err); + model.seq = seq + 1; + next(); + }); + } else { + next(); + } +} + diff --git a/lib/models/data-model.js b/lib/models/data-model.js deleted file mode 100644 index 416f8561..00000000 --- a/lib/models/data-model.js +++ /dev/null @@ -1,427 +0,0 @@ -/*! - * Module Dependencies. - */ -var Model = require('./model'); -var DataAccess = require('loopback-datasource-juggler/lib/dao'); - -/** - * Extends Model with basic query and CRUD support. - * - * **Change Event** - * - * Listen for model changes using the `change` event. - * - * ```js - * MyDataModel.on('changed', function(obj) { - * console.log(obj) // => the changed model - * }); - * ``` - * - * @class DataModel - * @param {Object} data - * @param {Number} data.id The default id property - */ - -var DataModel = module.exports = Model.extend('DataModel'); - -/*! - * Configure the remoting attributes for a given function - * @param {Function} fn The function - * @param {Object} options The options - * @private - */ - -function setRemoting(fn, options) { - options = options || {}; - for (var opt in options) { - if (options.hasOwnProperty(opt)) { - fn[opt] = options[opt]; - } - } - fn.shared = true; - // allow connectors to override the function by marking as delegate - fn._delegate = true; -} - -/*! - * 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 DataModel 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; - cb(error); -} - -/** - * Create new instance of Model class, saved in database. - * - * @param {Object} [data] Object containing model instance data. - * @callback {Function} callback Callback function; see below. - * @param {Error|null} err Error object - * @param {Model|null} Model instance - */ - -DataModel.create = function (data, callback) { - throwNotAttached(this.modelName, 'create'); -}; - -setRemoting(DataModel.create, { - description: 'Create a new instance of the model and persist it into the data source', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'post', path: '/'} -}); - -/** - * Update or insert a model instance - * @param {Object} data The model instance data - * @param {Function} [callback] The callback function - */ - -DataModel.upsert = function upsert(data, callback) { - throwNotAttached(this.modelName, 'updateOrCreate'); -}; - -/** - * Alias for upsert function. - */ -DataModel.updateOrCreate = DataModel.upsert; - -// upsert ~ remoting attributes -setRemoting(DataModel.upsert, { - description: 'Update an existing model instance or insert a new one into the data source', - accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'put', path: '/'} -}); - -/** - * Find one record instance, same as `all`, limited by one and return object, not collection. - * If not found, create the record using data provided as second argument. - * - * @param {Object} query - search conditions: {where: {test: 'me'}}. - * @param {Object} data - object to create. - * @param {Function} cb - callback called with (err, instance) - */ - -DataModel.findOrCreate = function findOrCreate(query, data, callback) { - throwNotAttached(this.modelName, 'findOrCreate'); -}; - -/** - * Check whether a model instance exists in database. - * - * @param {id} id - identifier of object (primary key value) - * @param {Function} cb - callbacl called with (err, exists: Bool) - */ - -DataModel.exists = function exists(id, cb) { - throwNotAttached(this.modelName, 'exists'); -}; - -// exists ~ remoting attributes -setRemoting(DataModel.exists, { - description: 'Check whether a model instance exists in the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - returns: {arg: 'exists', type: 'any'}, - http: {verb: 'get', path: '/:id/exists'} -}); - -/** - * Find object by id - * - * @param {*} id - primary key value - * @param {Function} cb - callback called with (err, instance) - */ - -DataModel.findById = function find(id, cb) { - throwNotAttached(this.modelName, 'find'); -}; - -// find ~ remoting attributes -setRemoting(DataModel.findById, { - description: 'Find a model instance by id from the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - returns: {arg: 'data', type: 'any', root: true}, - http: {verb: 'get', path: '/:id'}, - rest: {after: convertNullToNotFoundError} -}); - -/** - * Find all instances of Model, matched by query - * make sure you have marked as `index: true` fields for filter or sort - * - * @param {Object} params (optional) - * - * - where: Object `{ key: val, key2: {gt: 'val2'}}` - * - include: String, Object or Array. See DataModel.include documentation. - * - order: String - * - limit: Number - * - skip: Number - * - * @param {Function} callback (required) called with arguments: - * - * - err (null or Error) - * - Array of instances - */ - -DataModel.find = function find(params, cb) { - throwNotAttached(this.modelName, 'find'); -}; - -// all ~ remoting attributes -setRemoting(DataModel.find, { - description: 'Find all instances of the model matched by filter from the data source', - accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, - returns: {arg: 'data', type: 'array', root: true}, - http: {verb: 'get', path: '/'} -}); - -/** - * Find one record, same as `all`, limited by 1 and return object, not collection - * - * @param {Object} params - search conditions: {where: {test: 'me'}} - * @param {Function} cb - callback called with (err, instance) - */ - -DataModel.findOne = function findOne(params, cb) { - throwNotAttached(this.modelName, 'findOne'); -}; - -setRemoting(DataModel.findOne, { - description: 'Find first instance of the model matched by filter from the data source', - accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'get', path: '/findOne'} -}); - -/** - * Destroy all matching records - * @param {Object} [where] An object that defines the criteria - * @param {Function} [cb] - callback called with (err) - */ - -DataModel.destroyAll = function destroyAll(where, cb) { - throwNotAttached(this.modelName, 'destroyAll'); -}; - -/** - * Alias for `destroyAll` - */ - -DataModel.remove = DataModel.destroyAll; - -/** - * Alias for `destroyAll` - */ -DataModel.deleteAll = DataModel.destroyAll; - - -/** - * Destroy a record by id - * @param {*} id The id value - * @param {Function} cb - callback called with (err) - */ - -DataModel.destroyById = function deleteById(id, cb) { - throwNotAttached(this.modelName, 'deleteById'); -}; - -/** - * Alias for deleteById - */ -DataModel.deleteById = DataModel.destroyById; - -/** - * Alias for deleteById - */ -DataModel.removeById = DataModel.destroyById; - -// deleteById ~ remoting attributes -setRemoting(DataModel.deleteById, { - description: 'Delete a model instance by id from the data source', - accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, - http: {verb: 'del', path: '/:id'} -}); - -/** - * Return count of matched records - * - * @param {Object} where - search conditions (optional) - * @param {Function} cb - callback, called with (err, count) - */ - -DataModel.count = function (where, cb) { - throwNotAttached(this.modelName, 'count'); -}; - -// count ~ remoting attributes -setRemoting(DataModel.count, { - description: 'Count instances of the model matched by where from the data source', - accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, - returns: {arg: 'count', type: 'number'}, - http: {verb: 'get', path: '/count'} -}); - -/** - * Save instance. When instance does not have an ID, create method instead. - * Triggers: validate, save, update or create. - * - * @options [options] Options - * @property {Boolean} validate Whether to validate. - * @property {Boolean} throws - * @callback {Function} callback Callback function. - * @param {Error} err Error object - * @param {Object} - */ - -DataModel.prototype.save = function (options, callback) { - var inst = this; - var DataModel = inst.constructor; - - if(typeof options === 'function') { - callback = options; - options = {}; - } - - // delegates directly to DataAccess - DataAccess.prototype.save.call(this, options, function(err, data) { - if(err) return callback(data); - var saved = new DataModel(data); - inst.setId(saved.getId()); - callback(null, data); - }); -}; - - -/** - * Determine if the data model is new. - * @returns {Boolean} True if data model is new. - */ - -DataModel.prototype.isNewRecord = function () { - throwNotAttached(this.constructor.modelName, 'isNewRecord'); -}; - -/** - * Delete object from persistence - * - * @triggers `destroy` hook (async) before and after destroying object - */ - -DataModel.prototype.remove = -DataModel.prototype.delete = -DataModel.prototype.destroy = function (cb) { - throwNotAttached(this.constructor.modelName, 'destroy'); -}; - -/** - * Update single attribute. - * Equivalent to `updateAttributes({name: value}, cb)` - * - * @param {String} name - name of property - * @param {Mixed} value - value of property - * @param {Function} callback - callback called with (err, instance) - */ - -DataModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { - throwNotAttached(this.constructor.modelName, 'updateAttribute'); -}; - -/** - * Update set of attributes - * - * Performs validation before updating - * - * @trigger `validation`, `save` and `update` hooks - * @param {Object} data - data to update - * @param {Function} callback - callback called with (err, instance) - */ - -DataModel.prototype.updateAttributes = function updateAttributes(data, cb) { - throwNotAttached(this.modelName, 'updateAttributes'); -}; - -// updateAttributes ~ remoting attributes -setRemoting(DataModel.prototype.updateAttributes, { - description: 'Update attributes for a model instance and persist it into the data source', - accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, - returns: {arg: 'data', type: 'object', root: true}, - http: {verb: 'put', path: '/'} -}); - -/** - * Reload object from persistence - * - * @requires `id` member of `object` to be able to call `find` - * @callback {Function} callback Callback function - * @param {Error} err - * @param {Object} instance - */ - -DataModel.prototype.reload = function reload(callback) { - throwNotAttached(this.constructor.modelName, 'reload'); -}; - -/** - * Set the corret `id` property for the `DataModel`. If a `Connector` defines - * a `setId` method it will be used. Otherwise the default lookup is used. You - * should override this method to handle complex ids. - * - * @param {*} val The `id` value. Will be converted to the type the id property - * specifies. - */ - -DataModel.prototype.setId = function(val) { - var ds = this.getDataSource(); - this[this.getIdName()] = val; -} - -DataModel.prototype.getId = function() { - var data = this.toObject(); - if(!data) return; - return data[this.getIdName()]; -} - -/** - * Get the id property name of the constructor. - */ - -DataModel.prototype.getIdName = function() { - return this.constructor.getIdName(); -} - -/** - * Get the id property name - */ - -DataModel.getIdName = function() { - var Model = this; - var ds = Model.getDataSource(); - - if(ds.idName) { - return ds.idName(Model.modelName); - } else { - return 'id'; - } -} diff --git a/lib/models/model.js b/lib/models/model.js index b706ad86..6893e325 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -2,28 +2,99 @@ * Module Dependencies. */ var registry = require('../registry'); -var compat = require('../compat'); var assert = require('assert'); +var SharedClass = require('strong-remoting').SharedClass; /** - * The built in loopback.Model. + * The base class for **all models**. * + * **Inheriting from `Model`** + * + * ```js + * var properties = {...}; + * var options = {...}; + * var MyModel = loopback.Model.extend('MyModel', properties, options); + * ``` + * + * **Options** + * + * - `trackChanges` - If true, changes to the model will be tracked. **Required + * for replication.** + * + * **Events** + * + * #### Event: `changed` + * + * Emitted after a model has been successfully created, saved, or updated. + * + * ```js + * MyModel.on('changed', function(inst) { + * console.log('model with id %s has been changed', inst.id); + * // => model with id 1 has been changed + * }); + * ``` + * + * #### Event: `deleted` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deleted', function(inst) { + * console.log('model with id %s has been deleted', inst.id); + * // => model with id 1 has been deleted + * }); + * ``` + * + * #### Event: `deletedAll` + * + * Emitted after an individual model has been deleted. + * + * ```js + * MyModel.on('deletedAll', function(where) { + * if(where) { + * console.log('all models where', where, 'have been deleted'); + * // => all models where + * // => {price: {gt: 100}} + * // => have been deleted + * } + * }); + * ``` + * + * #### Event: `attached` + * + * Emitted after a `Model` has been attached to an `app`. + * + * #### Event: `dataSourceAttached` + * + * Emitted after a `Model` has been attached to a `DataSource`. + * * @class * @param {Object} data + * @property {String} modelName The name of the model + * @property {DataSource} dataSource */ var Model = module.exports = registry.modelBuilder.define('Model'); -Model.shared = true; - /*! * Called when a model is extended. */ Model.setup = function () { var ModelCtor = this; - + var options = this.settings; + + // create a sharedClass + var sharedClass = ModelCtor.sharedClass = new SharedClass( + ModelCtor.modelName, + ModelCtor, + options.remoting + ); + + // support remoting prototype methods ModelCtor.sharedCtor = function (data, id, fn) { + var ModelCtor = this; + if(typeof data === 'function') { fn = data; data = null; @@ -63,12 +134,24 @@ Model.setup = function () { } } + var idDesc = ModelCtor.modelName + ' id'; + ModelCtor.sharedCtor.accepts = [ + {arg: 'id', type: 'any', http: {source: 'path'}, description: idDesc} + // {arg: 'instance', type: 'object', http: {source: 'body'}} + ]; + + ModelCtor.sharedCtor.http = [ + {path: '/:id'} + ]; + + ModelCtor.sharedCtor.returns = {root: true}; + // before remote hook ModelCtor.beforeRemote = function (name, fn) { var self = this; if(this.app) { var remotes = this.app.remotes(); - var className = compat.getClassNameForRemoting(self); + var className = self.modelName; remotes.before(className + '.' + name, function (ctx, next) { fn(ctx, ctx.result, next); }); @@ -85,7 +168,7 @@ Model.setup = function () { var self = this; if(this.app) { var remotes = this.app.remotes(); - var className = compat.getClassNameForRemoting(self); + var className = self.modelName; remotes.after(className + '.' + name, function (ctx, next) { fn(ctx, ctx.result, next); }); @@ -97,19 +180,21 @@ Model.setup = function () { } }; - // Map the prototype method to /:id with data in the body - var idDesc = ModelCtor.modelName + ' id'; - ModelCtor.sharedCtor.accepts = [ - {arg: 'id', type: 'any', http: {source: 'path'}, description: idDesc} - // {arg: 'instance', type: 'object', http: {source: 'body'}} - ]; + // resolve relation functions + sharedClass.resolve(function resolver(define) { + var relations = ModelCtor.relations; + if(!relations) return; + // get the relations + for(var relationName in relations) { + var relation = relations[relationName]; + if(relation.type === 'belongsTo') { + ModelCtor.belongsToRemoting(relationName, relation, define) + } else { + ModelCtor.scopeRemoting(relationName, relation, define); + } + } + }); - ModelCtor.sharedCtor.http = [ - {path: '/:id'} - ]; - - ModelCtor.sharedCtor.returns = {root: true}; - return ModelCtor; }; @@ -140,6 +225,7 @@ Model._ACL = function getACL(ACL) { * @param {String|Error} err The error object * @param {Boolean} allowed True if the request is allowed; false otherwise. */ + Model.checkAccess = function(token, modelId, sharedMethod, callback) { var ANONYMOUS = require('./access-token').ANONYMOUS; token = token || ANONYMOUS; @@ -200,10 +286,8 @@ Model._getAccessTypeForMethod = function(method) { return ACL.WRITE; case 'count': return ACL.READ; - break; default: return ACL.EXECUTE; - break; } } @@ -229,48 +313,58 @@ Model.getApp = function(callback) { } /** - * Get the Model's `RemoteObjects`. - * - * @callback {Function} callback - * @param {Error} err - * @param {RemoteObjects} remoteObjects - * @end + * Enable remote invocation for the method with the given name. + * + * @param {String} name The name of the method. + * ```js + * // static method example (eg. Model.myMethod()) + * Model.remoteMethod('myMethod'); + * @param {Object} options The remoting options. + * See [loopback.remoteMethod()](http://docs.strongloop.com/display/DOC/Remote+methods+and+hooks#Remotemethodsandhooks-loopback.remoteMethod(fn,[options])) for details. */ -Model.remotes = function(callback) { - this.getApp(function(err, app) { - callback(null, app.remotes()); - }); +Model.remoteMethod = function(name, options) { + if(options.isStatic === undefined) { + options.isStatic = true; + } + this.sharedClass.defineMethod(name, options); } -/*! - * Create a proxy function for invoking remote methods. - * - * @param {SharedMethod} sharedMethod - */ +Model.belongsToRemoting = function(relationName, relation, define) { + var fn = this.prototype[relationName]; + define('__get__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + relationName}, + accepts: {arg: 'refresh', type: 'boolean', http: {source: 'query'}}, + description: 'Fetches belongsTo relation ' + relationName, + returns: {arg: relationName, type: relation.modelTo.modelName, root: true} + }, fn); +} -Model.createProxyMethod = function createProxyFunction(remoteMethod) { - var Model = this; - var scope = remoteMethod.isStatic ? Model : Model.prototype; - var original = scope[remoteMethod.name]; - - var fn = scope[remoteMethod.name] = function proxy() { - var args = Array.prototype.slice.call(arguments); - var lastArgIsFunc = typeof args[args.length - 1] === 'function'; - var callback; - if(lastArgIsFunc) { - callback = args.pop(); - } - - Model.remotes(function(err, remotes) { - remotes.invoke(remoteMethod.stringName, args, callback); - }); - } - - for(var key in original) { - fn[key] = original[key]; - } - fn._delegate = true; +Model.scopeRemoting = function(relationName, relation, define) { + var toModelName = relation.modelTo.modelName; + + define('__get__' + relationName, { + isStatic: false, + http: {verb: 'get', path: '/' + relationName}, + accepts: {arg: 'filter', type: 'object'}, + description: 'Queries ' + relationName + ' of ' + this.modelName + '.', + returns: {arg: relationName, type: [toModelName], root: true} + }); + + define('__create__' + relationName, { + isStatic: false, + http: {verb: 'post', path: '/' + relationName}, + accepts: {arg: 'data', type: 'object', http: {source: 'body'}}, + description: 'Creates a new instance in ' + relationName + ' of this model.', + returns: {arg: 'data', type: toModelName, root: true} + }); + + define('__delete__' + relationName, { + isStatic: false, + http: {verb: 'delete', path: '/' + relationName}, + description: 'Deletes all ' + relationName + ' of this model.' + }); } // setup the initial model diff --git a/lib/models/persisted-model.js b/lib/models/persisted-model.js new file mode 100644 index 00000000..f8c71798 --- /dev/null +++ b/lib/models/persisted-model.js @@ -0,0 +1,989 @@ +/*! + * Module Dependencies. + */ + +var Model = require('./model'); +var runtime = require('../runtime'); +var RemoteObjects = require('strong-remoting'); +var assert = require('assert'); +var async = require('async'); + +/** + * 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 + * @param {Object} data + * @param {Number} data.id The default id property + */ + +var PersistedModel = module.exports = Model.extend('PersistedModel'); + +/*! + * Setup the `PersistedModel` constructor. + */ + +PersistedModel.setup = function setupPersistedModel() { + // call Model.setup first + Model.setup.call(this); + + var PersistedModel = this; + var typeName = this.modelName; + + // setup a remoting type converter for this model + RemoteObjects.convert(typeName, function(val) { + return val ? new PersistedModel(val) : val; + }); + + // enable change tracking (usually for replication) + if(this.settings.trackChanges) { + PersistedModel._defineChangeModel(); + PersistedModel.once('dataSourceAttached', function() { + PersistedModel.enableChangeTracking(); + }); + } + + 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; + cb(error); +} + +/** + * Create new instance of Model class, saved in database + * + * @param data [optional] + * @param callback(err, obj) + * callback called with arguments: + * + * - err (null or Error) + * - instance (null or Model) + */ + +PersistedModel.create = function (data, callback) { + throwNotAttached(this.modelName, 'create'); +}; + +/** + * Update or insert a model instance + * @param {Object} data The model instance data + * @param {Function} [callback] The callback function + */ + +PersistedModel.upsert = PersistedModel.updateOrCreate = function upsert(data, callback) { + throwNotAttached(this.modelName, 'upsert'); +}; + +/** + * Find one record, same as `find`, limited by 1 and return object, not collection, + * if not found, create using data provided as second argument + * + * @param {Object} query - search conditions: {where: {test: 'me'}}. + * @param {Object} data - object to create. + * @param {Function} cb - callback called with (err, instance) + */ + +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) + * @param {Function} cb - callbacl called with (err, exists: Bool) + */ + +PersistedModel.exists = function exists(id, cb) { + throwNotAttached(this.modelName, 'exists'); +}; + +/** + * Find object by id + * + * @param {*} id - primary key value + * @param {Function} cb - callback called with (err, instance) + */ + +PersistedModel.findById = function find(id, cb) { + throwNotAttached(this.modelName, 'findById'); +}; + +/** + * Find all instances of Model, matched by query + * make sure you have marked as `index: true` fields for filter or sort + * + * @param {Object} params (optional) + * + * - where: Object `{ key: val, key2: {gt: 'val2'}}` + * - include: String, Object or Array. See PersistedModel.include documentation. + * - order: String + * - limit: Number + * - skip: Number + * + * @param {Function} callback (required) called with arguments: + * + * - err (null or Error) + * - Array of instances + */ + +PersistedModel.find = function find(params, cb) { + throwNotAttached(this.modelName, 'find'); +}; + +/** + * Find one record, same as `all`, limited by 1 and return object, not collection + * + * @param {Object} params - search conditions: {where: {test: 'me'}} + * @param {Function} cb - callback called with (err, instance) + */ + +PersistedModel.findOne = function findOne(params, cb) { + throwNotAttached(this.modelName, 'findOne'); +}; + +/** + * Destroy all matching records + * @param {Object} [where] An object that defines the criteria + * @param {Function} [cb] - callback called with (err) + */ + +PersistedModel.remove = +PersistedModel.deleteAll = +PersistedModel.destroyAll = function destroyAll(where, cb) { + throwNotAttached(this.modelName, 'destroyAll'); +}; + +/** + * Update multiple instances that match the where clause + * + * Example: + * + *```js + * Employee.update({managerId: 'x001'}, {managerId: 'x002'}, function(err) { + * ... + * }); + * ``` + * + * @param {Object} [where] Search conditions (optional) + * @param {Object} data Changes to be made + * @param {Function} cb Callback, called with (err, count) + */ +PersistedModel.update = + PersistedModel.updateAll = function updateAll(where, data, cb) { + throwNotAttached(this.modelName, 'updateAll'); + }; + +/** + * Destroy a record by id + * @param {*} id The id value + * @param {Function} cb - callback called with (err) + */ + +PersistedModel.removeById = +PersistedModel.deleteById = +PersistedModel.destroyById = function deleteById(id, cb) { + throwNotAttached(this.modelName, 'deleteById'); +}; + +/** + * Return count of matched records + * + * @param {Object} where - search conditions (optional) + * @param {Function} cb - callback, called with (err, count) + */ + +PersistedModel.count = function (where, cb) { + throwNotAttached(this.modelName, 'count'); +}; + +/** + * Save instance. When instance haven't id, create method called instead. + * Triggers: validate, save, update | create + * @param options {validate: true, throws: false} [optional] + * @param callback(err, obj) + */ + +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 ValidationError(inst); + // throws option is dangerous for async usage + if (options.throws) { + throw err; + } + callback(err, inst); + } + }); + + // then save + function save() { + inst.trigger('save', function (saveDone) { + inst.trigger('update', function (updateDone) { + Model.upsert(inst, function(err) { + inst._initProperties(data); + updateDone.call(inst, function () { + saveDone.call(inst, function () { + callback(err, inst); + }); + }); + }); + }, data); + }, data); + } +}; + +/** + * Determine if the data model is new. + * @returns {Boolean} + */ + +PersistedModel.prototype.isNewRecord = function () { + throwNotAttached(this.constructor.modelName, 'isNewRecord'); +}; + +/** + * Delete object from persistence + * + * @triggers `destroy` hook (async) before and after destroying object + */ + +PersistedModel.prototype.remove = +PersistedModel.prototype.delete = +PersistedModel.prototype.destroy = function (cb) { + throwNotAttached(this.constructor.modelName, 'destroy'); +}; + +PersistedModel.prototype.destroy._delegate = true; + +/** + * Update single attribute + * + * equals to `updateAttributes({name: value}, cb) + * + * @param {String} name - name of property + * @param {Mixed} value - value of property + * @param {Function} callback - callback called with (err, instance) + */ + +PersistedModel.prototype.updateAttribute = function updateAttribute(name, value, callback) { + throwNotAttached(this.constructor.modelName, 'updateAttribute'); +}; + +/** + * Update set of attributes + * + * this method performs validation before updating + * + * @trigger `validation`, `save` and `update` hooks + * @param {Object} data - data to update + * @param {Function} callback - callback called with (err, 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` + * @param {Function} callback - called with (err, instance) arguments + */ + +PersistedModel.prototype.reload = function reload(callback) { + throwNotAttached(this.constructor.modelName, 'reload'); +}; + +/** + * Set the correct `id` property for the `PersistedModel`. If a `Connector` defines + * a `setId` method it will be used. Otherwise the default lookup is used. You + * should override this method to handle complex ids. + * + * @param {*} val The `id` value. Will be converted to the type 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', + 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', { + description: 'Update an existing model instance or insert a new one into the data source', + accepts: {arg: 'data', type: 'object', description: 'Model instance data', http: {source: 'body'}}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'put', path: '/'} + }); + + setRemoting(PersistedModel, 'exists', { + description: 'Check whether a model instance exists in the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true}, + returns: {arg: 'exists', type: 'boolean'}, + http: {verb: 'get', path: '/:id/exists'} + }); + + setRemoting(PersistedModel, 'findById', { + description: 'Find a model instance by id from the data source', + accepts: { + arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'} + }, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'get', path: '/:id'}, + rest: {after: convertNullToNotFoundError} + }); + + setRemoting(PersistedModel, 'find', { + description: 'Find all instances of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: [typeName], root: true}, + http: {verb: 'get', path: '/'} + }); + + setRemoting(PersistedModel, 'findOne', { + description: 'Find first instance of the model matched by filter from the data source', + accepts: {arg: 'filter', type: 'object', description: 'Filter defining fields, where, orderBy, offset, and limit'}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'get', path: '/findOne'}, + rest: {after: convertNullToNotFoundError} + }); + + setRemoting(PersistedModel, 'destroyAll', { + description: 'Delete all matching records', + accepts: {arg: 'where', type: 'object', description: 'filter.where object'}, + http: {verb: 'del', path: '/'}, + shared: false + }); + + setRemoting(PersistedModel, 'updateAll', { + description: 'Update instances of the model matched by where from the data source', + 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', { + description: 'Delete a model instance by id from the data source', + accepts: {arg: 'id', type: 'any', description: 'Model id', required: true, + http: {source: 'path'}}, + http: {verb: 'del', path: '/:id'} + }); + + setRemoting(PersistedModel, 'count', { + description: 'Count instances of the model matched by where from the data source', + accepts: {arg: 'where', type: 'object', description: 'Criteria to match model instances'}, + returns: {arg: 'count', type: 'number'}, + http: {verb: 'get', path: '/count'} + }); + + setRemoting(PersistedModel.prototype, 'updateAttributes', { + description: 'Update attributes for a model instance and persist it into the data source', + accepts: {arg: 'data', type: 'object', http: {source: 'body'}, description: 'An object of model property name/value pairs'}, + returns: {arg: 'data', type: typeName, root: true}, + http: {verb: 'put', path: '/'} + }); + + if(options.trackChanges) { + setRemoting(PersistedModel, 'diff', { + description: 'Get a set of deltas and conflicts since the given checkpoint', + accepts: [ + {arg: 'since', type: 'number', description: 'Find deltas since this checkpoint'}, + {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.', + 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.', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'post', path: '/checkpoint'} + }); + + setRemoting(PersistedModel, 'currentCheckpoint', { + description: 'Get the current checkpoint.', + returns: {arg: 'checkpoint', type: 'object', root: true}, + http: {verb: 'get', path: '/checkpoint'} + }); + + setRemoting(PersistedModel, 'createUpdates', { + description: 'Create an update list from a delta list', + accepts: {arg: 'deltas', type: 'array', http: {source: 'body'}}, + returns: {arg: 'updates', type: 'array', root: true}, + http: {verb: 'post', path: '/create-updates'} + }); + + setRemoting(PersistedModel, 'bulkUpdate', { + description: 'Run multiple updates at once. Note: this is not atomic.', + accepts: {arg: 'updates', type: 'array'}, + http: {verb: 'post', path: '/bulk-update'} + }); + + setRemoting(PersistedModel, 'rectifyAllChanges', { + description: 'Rectify all Model changes.', + http: {verb: 'post', path: '/rectify-all'} + }); + + setRemoting(PersistedModel, 'rectifyChange', { + description: 'Tell loopback that a change to the model with the given id has occurred.', + accepts: {arg: 'id', type: 'any', http: {source: 'path'}}, + http: {verb: 'post', path: '/:id/rectify-change'} + }); + } +} + +/** + * Get a set of deltas and conflicts since the given checkpoint. + * + * See `Change.diff()` for details. + * + * @param {Number} since Find deltas since this checkpoint + * @param {Array} remoteChanges An array of change objects + * @param {Function} callback + */ + +PersistedModel.diff = function(since, remoteChanges, callback) { + var Change = this.getChangeModel(); + Change.diff(this.modelName, since, remoteChanges, callback); +} + +/** + * Get the changes to a model since a given checkpoint. Provide a filter object + * to reduce the number of results returned. + * @param {Number} since Only return changes since this checkpoint + * @param {Object} filter Only include changes that match this filter + * (same as `Model.find(filter, ...)`) + * @callback {Function} callback + * @param {Error} err + * @param {Array} changes An array of `Change` objects + * @end + */ + +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({ + checkpoint: {gt: since}, + modelName: this.modelName + }, function(err, changes) { + if(err) return cb(err); + var ids = changes.map(function(change) { + return change.getModelId(); + }); + filter.where[idName] = {inq: ids}; + model.find(filter, function(err, models) { + if(err) return cb(err); + var modelIds = models.map(function(m) { + return m[idName].toString(); + }); + 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 + * @param {Error} err + * @param {Number} currentCheckpointId + * @end + */ + +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] + * @param {Error} err + * @param {Conflict[]} conflicts A list of changes that could not be replicated + * due to conflicts. + */ + +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; + } + + options = options || {}; + + var sourceModel = this; + var diff; + var updates; + var Change = this.getChangeModel(); + var TargetChange = targetModel.getChangeModel(); + var changeTrackingEnabled = Change && TargetChange; + + assert( + changeTrackingEnabled, + 'You must enable change tracking before replicating' + ); + + callback = callback || function defaultReplicationCallback(err) { + if(err) throw err; + } + + var tasks = [ + getSourceChanges, + getDiffFromTarget, + createSourceUpdates, + bulkUpdate, + checkpoint + ]; + + async.waterfall(tasks, done); + + function getSourceChanges(cb) { + sourceModel.changes(since, options.filter, cb); + } + + function getDiffFromTarget(sourceChanges, cb) { + targetModel.diff(since, sourceChanges, cb); + } + + function createSourceUpdates(_diff, cb) { + diff = _diff; + diff.conflicts = diff.conflicts || []; + if(diff && diff.deltas && diff.deltas.length) { + sourceModel.createUpdates(diff.deltas, cb); + } else { + // nothing to replicate + done(); + } + } + + function bulkUpdate(updates, cb) { + targetModel.bulkUpdate(updates, cb); + } + + function checkpoint() { + var cb = arguments[arguments.length - 1]; + sourceModel.checkpoint(cb); + } + + function done(err) { + if(err) return callback(err); + + var conflicts = diff.conflicts.map(function(change) { + return new Change.Conflict( + change.modelId, sourceModel, targetModel + ); + }); + + if(conflicts.length) { + sourceModel.emit('conflicts', conflicts); + } + + callback && callback(null, conflicts); + } +} + +/** + * Create an update list (for `Model.bulkUpdate()`) from a delta list + * (result of `Change.diff()`). + * + * @param {Array} deltas + * @param {Function} callback + */ + +PersistedModel.createUpdates = function(deltas, cb) { + var Change = this.getChangeModel(); + var updates = []; + var Model = this; + var tasks = []; + + deltas.forEach(function(change) { + var change = new Change(change); + var type = change.type(); + var update = {type: type, change: change}; + switch(type) { + case Change.CREATE: + case Change.UPDATE: + tasks.push(function(cb) { + Model.findById(change.modelId, function(err, inst) { + if(err) return cb(err); + if(!inst) { + console.error('missing data for change:', change); + return cb && cb(new Error('missing data for change: ' + + change.modelId)); + } + if(inst.toObject) { + update.data = inst.toObject(); + } else { + update.data = inst; + } + updates.push(update); + cb(); + }); + }); + break; + case Change.DELETE: + updates.push(update); + break; + } + }); + + async.parallel(tasks, function(err) { + if(err) return cb(err); + cb(null, updates); + }); +} + +/** + * Apply an update list. + * + * **Note: this is not atomic** + * + * @param {Array} updates An updates list (usually from Model.createUpdates()) + * @param {Function} callback + */ + +PersistedModel.bulkUpdate = function(updates, callback) { + var tasks = []; + var Model = this; + var idName = this.dataSource.idName(this.modelName); + var Change = this.getChangeModel(); + + updates.forEach(function(update) { + switch(update.type) { + case Change.UPDATE: + case Change.CREATE: + // var model = new Model(update.data); + // tasks.push(model.save.bind(model)); + tasks.push(function(cb) { + var model = new Model(update.data); + model.save(cb); + }); + break; + case Change.DELETE: + var data = {}; + data[idName] = update.change.modelId; + var model = new Model(data); + tasks.push(model.destroy.bind(model)); + break; + } + }); + + async.parallel(tasks, callback); +} + +/** + * Get the `Change` model. + * + * @throws {Error} Throws an error if the change model is not correctly setup. + * @return {Change} + */ + +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 / dataSource. + * + * @callback {Function} callback + * @param {Error} err + * @param {String} sourceId + */ + +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'); + + Change.attachTo(this.dataSource); + Change.getCheckpointModel().attachTo(this.dataSource); + + Model.afterSave = function afterSave(next) { + Model.rectifyChange(this.getId(), next); + } + + Model.afterDestroy = function afterDestroy(next) { + Model.rectifyChange(this.getId(), next); + } + + Model.on('deletedAll', cleanup); + + if(runtime.isServer) { + // initial cleanup + cleanup(); + + // cleanup + setInterval(cleanup, cleanupInterval); + + function cleanup() { + Model.rectifyAllChanges(function(err) { + if(err) { + console.error(Model.modelName + ' Change Cleanup Error:'); + console.error(err); + } + }); + } + } +} + +PersistedModel._defineChangeModel = function() { + var BaseChangeModel = require('./change'); + return this.Change = BaseChangeModel.extend(this.modelName + '-change', + {}, + { + trackModel: this + } + ); +} + +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 + */ + +PersistedModel.handleChangeError = function(err) { + if(err) { + console.error(Model.modelName + ' Change Tracking Error:'); + console.error(err); + } +} + +/** + * Tell loopback that a change to the model with the given id has occurred. + * + * @param {*} id The id of the model that has changed + * @callback {Function} callback + * @param {Error} err + */ + +PersistedModel.rectifyChange = function(id, callback) { + var Change = this.getChangeModel(); + Change.rectifyModelChanges(this.modelName, [id], callback); +} + +PersistedModel.setup(); diff --git a/lib/models/user.js b/lib/models/user.js index 8b47b510..a6e413fd 100644 --- a/lib/models/user.js +++ b/lib/models/user.js @@ -2,8 +2,8 @@ * Module Dependencies. */ -var loopback = require('../loopback') - , Model = loopback.Model +var PersistedModel = require('../loopback').PersistedModel + , loopback = require('../loopback') , path = require('path') , SALT_WORK_FACTOR = 10 , crypto = require('crypto') @@ -54,7 +54,7 @@ var options = { principalType: ACL.ROLE, principalId: Role.OWNER, permission: ACL.ALLOW, - property: 'removeById' + property: 'deleteById' }, { principalType: ACL.ROLE, @@ -103,7 +103,7 @@ var options = { * * - DENY EVERYONE `*` * - ALLOW EVERYONE `create` - * - ALLOW OWNER `removeById` + * - ALLOW OWNER `deleteById` * - ALLOW EVERYONE `login` * - ALLOW EVERYONE `logout` * - ALLOW EVERYONE `findById` @@ -113,7 +113,7 @@ var options = { * @inherits {Model} */ -var User = module.exports = Model.extend('User', properties, options); +var User = module.exports = PersistedModel.extend('User', properties, options); /** * Create access token for the logged in user. This method can be overridden to @@ -434,7 +434,7 @@ User.resetPassword = function(options, cb) { User.setup = function () { // We need to call the base class's setup method - Model.setup.call(this); + PersistedModel.setup.call(this); var UserModel = this; // max ttl @@ -458,6 +458,7 @@ User.setup = function () { loopback.remoteMethod( UserModel.login, { + description: 'Login a user with username/email and password', accepts: [ {arg: 'credentials', type: 'object', required: true, http: {source: 'body'}}, {arg: 'include', type: 'string', http: {source: 'query' }, description: @@ -478,6 +479,7 @@ User.setup = function () { loopback.remoteMethod( UserModel.logout, { + description: 'Logout a user with access token', accepts: [ {arg: 'access_token', type: 'string', required: true, http: function(ctx) { var req = ctx && ctx.req; @@ -497,6 +499,7 @@ User.setup = function () { loopback.remoteMethod( UserModel.confirm, { + description: 'Confirm a user registration with email verification token', accepts: [ {arg: 'uid', type: 'string', required: true}, {arg: 'token', type: 'string', required: true}, @@ -509,6 +512,7 @@ User.setup = function () { loopback.remoteMethod( UserModel.resetPassword, { + description: 'Reset password for a user with email', accepts: [ {arg: 'options', type: 'object', required: true, http: {source: 'body'}} ], @@ -530,10 +534,12 @@ User.setup = function () { UserModel.email = require('./email'); UserModel.accessToken = require('./access-token'); - UserModel.validatesUniquenessOf('email', {message: 'Email already exists'}); + // email validation regex var re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + UserModel.validatesUniquenessOf('email', {message: 'Email already exists'}); UserModel.validatesFormatOf('email', {with: re, message: 'Must provide a valid email'}); + UserModel.validatesUniquenessOf('username', {message: 'User already exists'}); return UserModel; } diff --git a/lib/registry.js b/lib/registry.js index c209f668..8b581066 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -113,7 +113,7 @@ registry.createModel = function (name, properties, options) { } } - BaseModel = BaseModel || this.Model; + BaseModel = BaseModel || this.PersistedModel; var model = BaseModel.extend(name, properties, options); @@ -180,10 +180,26 @@ registry.configureModel = function(ModelCtor, config) { * @param {String} modelName The model name * @returns {Model} The model class * + * @header loopback.findModel(modelName) + */ +registry.findModel = function(modelName) { + return this.modelBuilder.models[modelName]; +}; + +/** + * Look up a model class by name from all models created by + * `loopback.createModel()`. Throw an error when no such model exists. + * + * @param {String} modelName The model name + * @returns {Model} The model class + * * @header loopback.getModel(modelName) */ registry.getModel = function(modelName) { - return this.modelBuilder.models[modelName]; + var model = this.findModel(modelName); + if (model) return model; + + throw new Error('Model not found: ' + modelName); }; /** @@ -330,7 +346,19 @@ registry.DataSource = DataSource; */ registry.Model = require('./models/model'); -registry.DataModel = require('./models/data-model'); +registry.PersistedModel = require('./models/persisted-model'); + +// temporary alias to simplify migration of code based on <=2.0.0-beta3 +Object.defineProperty(registry, 'DataModel', { + get: function() { + var stackLines = new Error().stack.split('\n'); + console.warn('loopback.DataModel is deprecated, ' + + 'use loopback.PersistedModel instead.'); + // Log the location where loopback.DataModel was called + console.warn(stackLines[2]); + return this.PersistedModel; + } +}); // Set the default model base class. This is done after the Model class is defined. registry.modelBuilder.defaultModelBaseClass = registry.Model; diff --git a/package.json b/package.json index 6a829e2d..1e337f72 100644 --- a/package.json +++ b/package.json @@ -26,48 +26,59 @@ "mobile", "mBaaS" ], - "version": "1.10.0", + "version": "2.0.0-beta7", "scripts": { - "test": "mocha -R spec" + "test": "grunt mocha-and-karma" }, "dependencies": { "async": "~0.9.0", - "bcryptjs": "~2.0.1", - "debug": "~1.0.3", + "body-parser": "~1.4.3", + "canonical-json": "0.0.4", "ejs": "~1.0.0", - "express": "3.x", + "express": "4.x", + "strong-remoting": "~2.0.0-beta5", + "bcryptjs": "~2.0.1", + "debug": "~1.0.4", "inflection": "~1.3.8", - "nodemailer": "~0.7.1", - "strong-remoting": "~1.5.1", + "nodemailer": "~1.0.1", + "nodemailer-stub-transport": "~0.1.4", "uid2": "0.0.3", "underscore": "~1.6.0", "underscore.string": "~2.3.3" }, "peerDependencies": { - "loopback-datasource-juggler": "^1.7.0" + "loopback-datasource-juggler": "~2.0.0-beta3" }, "devDependencies": { - "loopback-datasource-juggler": "^1.7.0", - "mocha": "~1.20.1", - "strong-task-emitter": "0.0.x", - "supertest": "~0.13.0", - "chai": "~1.9.1", - "loopback-testing": "~0.2.0", "browserify": "~4.2.1", + "chai": "~1.9.1", + "cookie-parser": "~1.3.2", + "errorhandler": "~1.1.1", + "es5-shim": "^4.0.0", "grunt": "~0.4.5", "grunt-browserify": "~2.1.3", - "grunt-contrib-uglify": "~0.5.0", + "grunt-cli": "^0.1.13", "grunt-contrib-jshint": "~0.10.0", + "grunt-contrib-uglify": "~0.5.0", "grunt-contrib-watch": "~0.6.1", - "karma-script-launcher": "~0.1.0", + "grunt-karma": "~0.8.3", + "grunt-mocha-test": "^0.11.0", + "karma-browserify": "~0.2.1", "karma-chrome-launcher": "~0.1.4", "karma-firefox-launcher": "~0.1.3", "karma-html2js-preprocessor": "~0.1.0", + "karma-junit-reporter": "^0.2.2", + "karma-mocha": "^0.1.4", "karma-phantomjs-launcher": "~0.1.4", - "karma": "~0.12.17", - "karma-browserify": "~0.2.1", - "karma-mocha": "~0.1.4", - "grunt-karma": "~0.8.3" + "karma-script-launcher": "~0.1.0", + "loopback-boot": "^1.1.0", + "loopback-datasource-juggler": "~2.0.0-beta3", + "loopback-testing": "~0.2.0", + "mocha": "~1.20.1", + "serve-favicon": "~2.0.1", + "strong-task-emitter": "0.0.x", + "supertest": "~0.13.0", + "karma": "~0.12.17" }, "repository": { "type": "git", diff --git a/test/access-token.test.js b/test/access-token.test.js index d03a4916..94260b8d 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -200,7 +200,7 @@ function createTestApp(testToken, settings, done) { principalId: "$everyone", accessType: ACL.ALL, permission: ACL.DENY, - property: 'removeById' + property: 'deleteById' } ] }; @@ -209,7 +209,7 @@ function createTestApp(testToken, settings, done) { modelOptions[key] = modelSettings[key]; }); - var TestModel = loopback.Model.extend('test', {}, modelOptions); + var TestModel = loopback.PersistedModel.extend('test', {}, modelOptions); TestModel.attachTo(loopback.memory()); app.model(TestModel); diff --git a/test/acl.test.js b/test/acl.test.js index c4e7bb8f..ba97dda9 100644 --- a/test/acl.test.js +++ b/test/acl.test.js @@ -21,7 +21,8 @@ before(function() { describe('security scopes', function () { beforeEach(function() { - testModel = loopback.Model.extend('testModel'); + var ds = this.ds = loopback.createDataSource({connector: loopback.Memory}); + testModel = loopback.PersistedModel.extend('testModel'); ACL.attachTo(ds); Role.attachTo(ds); RoleMapping.attachTo(ds); diff --git a/test/app.test.js b/test/app.test.js index 5fbdeb27..8bed1cb2 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -1,5 +1,10 @@ var path = require('path'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); +var loopback = require('../'); +var PersistedModel = loopback.PersistedModel; + +var describe = require('./util/describe'); +var it = require('./util/it'); describe('app', function() { @@ -11,22 +16,26 @@ describe('app', function() { }); it("Expose a `Model` to remote clients", function() { - var Color = db.createModel('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); expect(app.models()).to.eql([Color]); }); it('uses singlar name as app.remoteObjects() key', function() { - var Color = db.createModel('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); expect(app.remoteObjects()).to.eql({ color: Color }); }); it('uses singular name as shared class name', function() { - var Color = db.createModel('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); - expect(app.remotes().exports).to.eql({ color: Color }); + Color.attachTo(db); + var classes = app.remotes().classes().map(function(c) {return c.name}); + expect(classes).to.contain('color'); }); it('registers existing models to app.models', function() { @@ -38,36 +47,16 @@ describe('app', function() { expect(app.models.Color).to.equal(Color); }); - it('updates REST API when a new model is added', function(done) { + it.onServer('updates REST API when a new model is added', function(done) { app.use(loopback.rest()); request(app).get('/colors').expect(404, function(err, res) { if (err) return done(err); - var Color = db.createModel('color', {name: String}); + var Color = PersistedModel.extend('color', {name: String}); app.model(Color); + Color.attachTo(db); request(app).get('/colors').expect(200, done); }); }); - - describe('in compat mode', function() { - before(function() { - loopback.compat.usePluralNamesForRemoting = true; - }); - after(function() { - loopback.compat.usePluralNamesForRemoting = false; - }); - - it('uses plural name as shared class name', function() { - var Color = db.createModel('color', {name: String}); - app.model(Color); - expect(app.remotes().exports).to.eql({ colors: Color }); - }); - - it('uses plural name as app.remoteObjects() key', function() { - var Color = db.createModel('color', {name: String}); - app.model(Color); - expect(app.remoteObjects()).to.eql({ colors: Color }); - }); - }); }); describe('app.model(name, config)', function () { @@ -75,13 +64,8 @@ describe('app', function() { beforeEach(function() { app = loopback(); - app.boot({ - app: {port: 3000, host: '127.0.0.1'}, - dataSources: { - db: { - connector: 'memory' - } - } + app.dataSource('db', { + connector: 'memory' }); }); @@ -177,286 +161,7 @@ describe('app', function() { }); }); - describe('app.boot([options])', function () { - beforeEach(function () { - app.boot({ - app: { - port: 3000, - host: '127.0.0.1', - restApiRoot: '/rest-api', - foo: {bar: 'bat'}, - baz: true - }, - models: { - 'foo-bar-bat-baz': { - options: { - plural: 'foo-bar-bat-bazzies' - }, - dataSource: 'the-db' - } - }, - dataSources: { - 'the-db': { - connector: 'memory' - } - } - }); - }); - - it('should have port setting', function () { - assert.equal(this.app.get('port'), 3000); - }); - - it('should have host setting', function() { - assert.equal(this.app.get('host'), '127.0.0.1'); - }); - - it('should have restApiRoot setting', function() { - assert.equal(this.app.get('restApiRoot'), '/rest-api'); - }); - - it('should have other settings', function () { - expect(this.app.get('foo')).to.eql({ - bar: 'bat' - }); - expect(this.app.get('baz')).to.eql(true); - }); - - describe('boot and models directories', function() { - beforeEach(function() { - var app = this.app = loopback(); - app.boot(SIMPLE_APP); - }); - - it('should run all modules in the boot directory', function () { - assert(process.loadedFooJS); - delete process.loadedFooJS; - }); - - it('should run all modules in the models directory', function () { - assert(process.loadedBarJS); - delete process.loadedBarJS; - }); - }); - - describe('PaaS and npm env variables', function() { - beforeEach(function() { - this.boot = function () { - var app = loopback(); - app.boot({ - app: { - port: undefined, - host: undefined - } - }); - return app; - } - }); - - it('should be honored', function() { - var assertHonored = function (portKey, hostKey) { - process.env[hostKey] = randomPort(); - process.env[portKey] = randomHost(); - var app = this.boot(); - assert.equal(app.get('port'), process.env[portKey]); - assert.equal(app.get('host'), process.env[hostKey]); - delete process.env[portKey]; - delete process.env[hostKey]; - }.bind(this); - - assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_NODEJS_IP'); - assertHonored('npm_config_port', 'npm_config_host'); - assertHonored('npm_package_config_port', 'npm_package_config_host'); - assertHonored('OPENSHIFT_SLS_PORT', 'OPENSHIFT_SLS_IP'); - assertHonored('PORT', 'HOST'); - }); - - it('should be honored in order', function() { - process.env.npm_config_host = randomHost(); - process.env.OPENSHIFT_SLS_IP = randomHost(); - process.env.OPENSHIFT_NODEJS_IP = randomHost(); - process.env.HOST = randomHost(); - process.env.npm_package_config_host = randomHost(); - - var app = this.boot(); - assert.equal(app.get('host'), process.env.npm_config_host); - - delete process.env.npm_config_host; - delete process.env.OPENSHIFT_SLS_IP; - delete process.env.OPENSHIFT_NODEJS_IP; - delete process.env.HOST; - delete process.env.npm_package_config_host; - - process.env.npm_config_port = randomPort(); - process.env.OPENSHIFT_SLS_PORT = randomPort(); - process.env.OPENSHIFT_NODEJS_PORT = randomPort(); - process.env.PORT = randomPort(); - process.env.npm_package_config_port = randomPort(); - - var app = this.boot(); - assert.equal(app.get('host'), process.env.npm_config_host); - assert.equal(app.get('port'), process.env.npm_config_port); - - delete process.env.npm_config_port; - delete process.env.OPENSHIFT_SLS_PORT; - delete process.env.OPENSHIFT_NODEJS_PORT; - delete process.env.PORT; - delete process.env.npm_package_config_port; - }); - - function randomHost() { - return Math.random().toString().split('.')[1]; - } - - function randomPort() { - return Math.floor(Math.random() * 10000); - } - - it('should honor 0 for free port', function () { - var app = loopback(); - app.boot({app: {port: 0}}); - assert.equal(app.get('port'), 0); - }); - - it('should default to port 3000', function () { - var app = loopback(); - app.boot({app: {port: undefined}}); - assert.equal(app.get('port'), 3000); - }); - }); - - it('Instantiate models', function () { - assert(app.models); - assert(app.models.FooBarBatBaz); - assert(app.models.fooBarBatBaz); - assertValidDataSource(app.models.FooBarBatBaz.dataSource); - assert.isFunc(app.models.FooBarBatBaz, 'find'); - assert.isFunc(app.models.FooBarBatBaz, 'create'); - }); - - it('Attach models to data sources', function () { - assert.equal(app.models.FooBarBatBaz.dataSource, app.dataSources.theDb); - }); - - it('Instantiate data sources', function () { - assert(app.dataSources); - assert(app.dataSources.theDb); - assertValidDataSource(app.dataSources.theDb); - assert(app.dataSources.TheDb); - }); - }); - - describe('app.boot(appRootDir)', function () { - it('Load config files', function () { - var app = loopback(); - - app.boot(SIMPLE_APP); - - assert(app.models.foo); - assert(app.models.Foo); - assert(app.models.Foo.dataSource); - assert.isFunc(app.models.Foo, 'find'); - assert.isFunc(app.models.Foo, 'create'); - }); - }); - - describe('installMiddleware()', function() { - var app; - beforeEach(function() { app = loopback(); }); - - it('installs loopback.token', function(done) { - app.models.accessToken = loopback.AccessToken; - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ accessTokenId: req.accessToken && req.accessToken.id }); - }); - - app.models.accessToken.create({}, function(err, token) { - if (err) done(err); - request(app).get('/') - .set('Authorization', token.id) - .expect(200, { accessTokenId: token.id }) - .end(done); - }); - }); - - it('emits "middleware:preprocessors" before handlers are installed', - function(done) { - app.on('middleware:preprocessors', function() { - this.use(function(req, res, next) { - req.preprocessed = true; - next(); - }); - }); - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ preprocessed: req.preprocessed }); - }); - - request(app).get('/') - .expect(200, { preprocessed: true}) - .end(done); - } - ); - - it('emits "middleware:handlers before installing express router', - function(done) { - app.on('middleware:handlers', function() { - this.use(function(req, res, next) { - res.send({ handler: 'middleware' }); - }); - }); - - app.installMiddleware(); - - app.get('/', function(req, res) { - res.send({ handler: 'router' }); - }); - - request(app).get('/') - .expect(200, { handler: 'middleware' }) - .end(done); - } - ); - - it('emits "middleware:error-handlers" after all request handlers', - function(done) { - var logs = []; - app.on('middleware:error-handlers', function() { - app.use(function(err, req, res, next) { - logs.push(req.url); - next(err); - }); - }); - - app.installMiddleware(); - - request(app).get('/not-found') - .expect(404) - .end(function(err, res) { - if (err) done(err); - expect(logs).to.eql(['/not-found']); - done(); - }); - } - ); - - it('installs REST transport', function(done) { - app.model(loopback.Application); - app.set('restApiRoot', '/api'); - app.installMiddleware(); - - request(app).get('/api/applications') - .expect(200, []) - .end(done); - }); - }); - - describe('listen()', function() { + describe.onServer('listen()', function() { it('starts http server', function(done) { var app = loopback(); app.set('port', 0); @@ -528,7 +233,7 @@ describe('app', function() { }); }); - describe('enableAuth', function() { + describe.onServer('enableAuth', function() { it('should set app.isAuthEnabled to true', function() { expect(app.isAuthEnabled).to.not.equal(true); app.enableAuth(); @@ -536,7 +241,7 @@ describe('app', function() { }); }); - describe('app.get("/", loopback.status())', function () { + describe.onServer('app.get("/", loopback.status())', function () { it('should return the status of the application', function (done) { var app = loopback(); app.get('/', loopback.status()); @@ -598,6 +303,28 @@ describe('app', function() { }); }); + describe('app.settings', function() { + it('can be altered via `app.set(key, value)`', function() { + app.set('write-key', 'write-value'); + expect(app.settings).to.have.property('write-key', 'write-value'); + }); + + it('can be read via `app.get(key)`', function() { + app.settings['read-key'] = 'read-value'; + expect(app.get('read-key')).to.equal('read-value'); + }); + + it('is unique per app instance', function() { + var app1 = loopback(); + var app2 = loopback(); + + expect(app1.settings).to.not.equal(app2.settings); + + app1.set('key', 'value'); + expect(app2.get('key'), 'app2 value').to.equal(undefined); + }); + }); + it('exposes loopback as a property', function() { var app = loopback(); expect(app.loopback).to.equal(loopback); diff --git a/test/change.test.js b/test/change.test.js new file mode 100644 index 00000000..4c34eace --- /dev/null +++ b/test/change.test.js @@ -0,0 +1,291 @@ +var Change; +var TestModel; + +describe('Change', function(){ + beforeEach(function() { + var memory = loopback.createDataSource({ + connector: loopback.Memory + }); + TestModel = loopback.PersistedModel.extend('chtest', {}, { + trackChanges: true + }); + this.modelName = TestModel.modelName; + TestModel.attachTo(memory); + Change = TestModel.getChangeModel(); + }); + + beforeEach(function(done) { + var test = this; + test.data = { + foo: 'bar' + }; + TestModel.create(test.data, function(err, model) { + if(err) return done(err); + test.model = model; + test.modelId = model.id; + test.revisionForModel = Change.revisionForInst(model); + done(); + }); + }); + + describe('change.id', function () { + it('should be a hash of the modelName and modelId', function () { + var change = new Change({ + rev: 'abc', + modelName: 'foo', + modelId: 'bar' + }); + + var hash = Change.hash([change.modelName, change.modelId].join('-')); + + assert.equal(change.id, hash); + }); + }); + + describe('Change.rectifyModelChanges(modelName, modelIds, callback)', function () { + describe('using an existing untracked model', function () { + beforeEach(function(done) { + var test = this; + Change.rectifyModelChanges(this.modelName, [this.modelId], function(err, trackedChanges) { + if(err) return done(err); + done(); + }); + }); + + it('should create an entry', function (done) { + var test = this; + Change.find(function(err, trackedChanges) { + assert.equal(trackedChanges[0].modelId, test.modelId.toString()); + done(); + }); + }); + + it('should only create one change', function (done) { + Change.count(function(err, count) { + assert.equal(count, 1); + done(); + }); + }); + }); + }); + + describe('Change.findOrCreateChange(modelName, modelId, callback)', function () { + + describe('when a change doesnt exist', function () { + beforeEach(function(done) { + var test = this; + Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { + if(err) return done(err); + test.result = result; + done(); + }); + }); + + it('should create an entry', function (done) { + var test = this; + Change.findById(this.result.id, function(err, change) { + assert.equal(change.id, test.result.id); + done(); + }); + }); + }); + + describe('when a change does exist', function () { + beforeEach(function(done) { + var test = this; + Change.create({ + modelName: test.modelName, + modelId: test.modelId + }, function(err, change) { + test.existingChange = change; + done(); + }); + }); + + beforeEach(function(done) { + var test = this; + Change.findOrCreateChange(this.modelName, this.modelId, function(err, result) { + if(err) return done(err); + test.result = result; + done(); + }); + }); + + it('should find the entry', function (done) { + var test = this; + assert.equal(test.existingChange.id, test.result.id); + done(); + }); + }); + }); + + describe('change.rectify(callback)', function () { + it('should create a new change with the correct revision', function (done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.rectify(function(err, ch) { + assert.equal(ch.rev, test.revisionForModel); + done(); + }); + }); + }); + + describe('change.currentRevision(callback)', function () { + it('should get the correct revision', function (done) { + var test = this; + var change = new Change({ + modelName: this.modelName, + modelId: this.modelId + }); + + change.currentRevision(function(err, rev) { + assert.equal(rev, test.revisionForModel); + done(); + }); + }); + }); + + describe('Change.hash(str)', function () { + // todo(ritch) test other hashing algorithms + it('should hash the given string', function () { + var str = 'foo'; + var hash = Change.hash(str); + assert(hash !== str); + assert(typeof hash === 'string'); + }); + }); + + describe('Change.revisionForInst(inst)', function () { + it('should return the same revision for the same data', function () { + var a = { + b: { + b: ['c', 'd'], + c: ['d', 'e'] + } + }; + var b = { + b: { + c: ['d', 'e'], + b: ['c', 'd'] + } + }; + + var aRev = Change.revisionForInst(a); + var bRev = Change.revisionForInst(b); + assert.equal(aRev, bRev); + }); + }); + + describe('change.type()', function () { + it('CREATE', function () { + var change = new Change({ + rev: this.revisionForModel + }); + assert.equal(Change.CREATE, change.type()); + }); + it('UPDATE', function () { + var change = new Change({ + rev: this.revisionForModel, + prev: this.revisionForModel + }); + assert.equal(Change.UPDATE, change.type()); + }); + it('DELETE', function () { + var change = new Change({ + prev: this.revisionForModel + }); + assert.equal(Change.DELETE, change.type()); + }); + it('UNKNOWN', function () { + var change = new Change(); + assert.equal(Change.UNKNOWN, change.type()); + }); + }); + + describe('change.getModelCtor()', function () { + it('should get the correct model class', function () { + var change = new Change({ + modelName: this.modelName + }); + + assert.equal(change.getModelCtor(), TestModel); + }); + }); + + describe('change.equals(otherChange)', function () { + it('should return true when the change is equal', function () { + var change = new Change({ + rev: this.revisionForModel + }); + + var otherChange = new Change({ + rev: this.revisionForModel + }); + + assert.equal(change.equals(otherChange), true); + }); + + it('should return true when both changes are deletes', function () { + var REV = 'foo'; + var change = new Change({ + rev: null, + prev: REV, + }); + + var otherChange = new Change({ + rev: undefined, + prev: REV + }); + + assert.equal(change.type(), Change.DELETE); + assert.equal(otherChange.type(), Change.DELETE); + + assert.equal(change.equals(otherChange), true); + }); + }); + + describe('change.isBasedOn(otherChange)', function () { + it('should return true when the change is based on the other', function () { + var change = new Change({ + prev: this.revisionForModel + }); + + var otherChange = new Change({ + rev: this.revisionForModel + }); + + assert.equal(change.isBasedOn(otherChange), true); + }); + }); + + describe('Change.diff(modelName, since, remoteChanges, callback)', function () { + beforeEach(function(done) { + Change.create([ + {rev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + {rev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + {rev: 'bat', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ], done); + }); + + it('should return delta and conflict lists', function (done) { + var remoteChanges = [ + // an update => should result in a delta + {rev: 'foo2', prev: 'foo', modelName: this.modelName, modelId: 9, checkpoint: 1}, + // no change => should not result in a delta / conflict + {rev: 'bar', prev: 'bar', modelName: this.modelName, modelId: 10, checkpoint: 1}, + // a conflict => should result in a conflict + {rev: 'bat2', prev: 'bat0', modelName: this.modelName, modelId: 11, checkpoint: 1}, + ]; + + Change.diff(this.modelName, 0, remoteChanges, function(err, diff) { + assert.equal(diff.deltas.length, 1); + assert.equal(diff.conflicts.length, 1); + done(); + }); + }); + }); +}); diff --git a/test/checkpoint.test.js b/test/checkpoint.test.js new file mode 100644 index 00000000..53a9a988 --- /dev/null +++ b/test/checkpoint.test.js @@ -0,0 +1,24 @@ +var async = require('async'); +var loopback = require('../'); + +// create a unique Checkpoint model +var Checkpoint = require('../lib/models/checkpoint').extend('TestCheckpoint'); +Checkpoint.attachTo(loopback.memory()); + +describe('Checkpoint', function() { + describe('current()', function() { + it('returns the highest `seq` value', function(done) { + async.series([ + Checkpoint.create.bind(Checkpoint), + Checkpoint.create.bind(Checkpoint), + function(next) { + Checkpoint.current(function(err, seq) { + if (err) next(err); + expect(seq).to.equal(2); + next(); + }); + } + ], done); + }); + }); +}); diff --git a/test/data-source.test.js b/test/data-source.test.js index f5d9c6b7..a1e0e126 100644 --- a/test/data-source.test.js +++ b/test/data-source.test.js @@ -35,12 +35,15 @@ describe('DataSource', function() { }); }); - describe('dataSource.operations()', function() { - it("List the enabled and disabled operations", function() { + describe.skip('PersistedModel Methods', function() { + it("List the enabled and disabled methods", function() { + var TestModel = loopback.PersistedModel.extend('TestPersistedModel'); + TestModel.attachTo(loopback.memory()); + // assert the defaults // - true: the method should be remote enabled // - false: the method should not be remote enabled - // - + // - existsAndShared('_forDB', false); existsAndShared('create', true); existsAndShared('updateOrCreate', true); @@ -61,11 +64,15 @@ describe('DataSource', function() { existsAndShared('destroyById', true); existsAndShared('destroy', false); existsAndShared('updateAttributes', true); + existsAndShared('updateAll', true); existsAndShared('reload', false); - function existsAndShared(name, isRemoteEnabled) { - var op = memory.getOperation(name); - assert(op.remoteEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); + function existsAndShared(Model, name, isRemoteEnabled, isProto) { + var scope = isProto ? Model.prototype : Model; + var fn = scope[name]; + var actuallyEnabled = Model.getRemoteMethod(name); + assert(fn, name + ' should be defined!'); + assert(actuallyEnabled === isRemoteEnabled, name + ' ' + (isRemoteEnabled ? 'should' : 'should not') + ' be remote enabled'); } }); }); diff --git a/test/e2e/remote-connector.e2e.js b/test/e2e/remote-connector.e2e.js index 3fb806fb..47c0d7bb 100644 --- a/test/e2e/remote-connector.e2e.js +++ b/test/e2e/remote-connector.e2e.js @@ -7,12 +7,10 @@ var assert = require('assert'); describe('RemoteConnector', function() { before(function() { // setup the remote connector - var localApp = loopback(); var ds = loopback.createDataSource({ url: 'http://localhost:3000/api', connector: loopback.Remote }); - localApp.model(TestModel); TestModel.attachTo(ds); }); @@ -32,7 +30,7 @@ describe('RemoteConnector', function() { }); m.save(function(err, data) { if(err) return done(err); - assert(m.id); + assert(data.foo === 'bar'); done(); }); }); diff --git a/test/e2e/replication.e2e.js b/test/e2e/replication.e2e.js new file mode 100644 index 00000000..fc42def4 --- /dev/null +++ b/test/e2e/replication.e2e.js @@ -0,0 +1,37 @@ +var path = require('path'); +var loopback = require('../../'); +var models = require('../fixtures/e2e/models'); +var TestModel = models.TestModel; +var LocalTestModel = TestModel.extend('LocalTestModel', {}, { + trackChanges: true +}); +var assert = require('assert'); + +describe('Replication', function() { + before(function() { + // setup the remote connector + var ds = loopback.createDataSource({ + url: 'http://localhost:3000/api', + connector: loopback.Remote + }); + TestModel.attachTo(ds); + var memory = loopback.memory(); + LocalTestModel.attachTo(memory); + }); + + it('should replicate local data to the remote', function (done) { + var RANDOM = Math.random(); + + LocalTestModel.create({ + n: RANDOM + }, function(err, created) { + LocalTestModel.replicate(0, TestModel, function() { + if(err) return done(err); + TestModel.findOne({n: RANDOM}, function(err, found) { + assert.equal(created.id, found.id); + done(); + }); + }); + }); + }); +}); diff --git a/test/email.test.js b/test/email.test.js index 9a23ecb8..325330a0 100644 --- a/test/email.test.js +++ b/test/email.test.js @@ -24,7 +24,8 @@ describe('Email and SMTP', function () { }; MyEmail.send(options, function(err, mail) { - assert(mail.message); + assert(!err); + assert(mail.response); assert(mail.envelope); assert(mail.messageId); done(err); @@ -41,7 +42,7 @@ describe('Email and SMTP', function () { }); message.send(function (err, mail) { - assert(mail.message); + assert(mail.response); assert(mail.envelope); assert(mail.messageId); done(err); diff --git a/test/fixtures/access-control/app.js b/test/fixtures/access-control/app.js index e4c21056..a8736a44 100644 --- a/test/fixtures/access-control/app.js +++ b/test/fixtures/access-control/app.js @@ -1,14 +1,15 @@ var loopback = require('../../../'); +var boot = require('loopback-boot'); var path = require('path'); var app = module.exports = loopback(); -app.boot(__dirname); +boot(app, __dirname); var apiPath = '/api'; app.use(loopback.cookieParser('secret')); app.use(loopback.token({model: app.models.accessToken})); app.use(apiPath, loopback.rest()); -app.use(app.router); + app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); app.enableAuth(); diff --git a/test/fixtures/access-control/models.json b/test/fixtures/access-control/models.json index 6db81abe..a1385443 100644 --- a/test/fixtures/access-control/models.json +++ b/test/fixtures/access-control/models.json @@ -123,7 +123,7 @@ "permission": "DENY", "principalType": "ROLE", "principalId": "$owner", - "property": "removeById" + "property": "deleteById" } ] }, diff --git a/test/fixtures/e2e/app.js b/test/fixtures/e2e/app.js index 337d6145..608b3d7e 100644 --- a/test/fixtures/e2e/app.js +++ b/test/fixtures/e2e/app.js @@ -3,12 +3,18 @@ var path = require('path'); var app = module.exports = loopback(); var models = require('./models'); var TestModel = models.TestModel; +// var explorer = require('loopback-explorer'); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; app.use(apiPath, loopback.rest()); + +TestModel.attachTo(loopback.memory()); +app.model(TestModel); +app.model(TestModel.getChangeModel()); + +// app.use('/explorer', explorer(app, {basePath: apiPath})); + app.use(loopback.static(path.join(__dirname, 'public'))); app.use(loopback.urlNotFound()); app.use(loopback.errorHandler()); -app.model(TestModel); -TestModel.attachTo(loopback.memory()); diff --git a/test/fixtures/e2e/models.js b/test/fixtures/e2e/models.js index dad14f61..3574c5bc 100644 --- a/test/fixtures/e2e/models.js +++ b/test/fixtures/e2e/models.js @@ -1,4 +1,6 @@ var loopback = require('../../../'); -var DataModel = loopback.DataModel; +var PersistedModel = loopback.PersistedModel; -exports.TestModel = DataModel.extend('TestModel'); +exports.TestModel = PersistedModel.extend('TestModel', {}, { + trackChanges: true +}); diff --git a/test/fixtures/simple-integration-app/app.js b/test/fixtures/simple-integration-app/app.js index 5f71b1d3..a4cbb2a4 100644 --- a/test/fixtures/simple-integration-app/app.js +++ b/test/fixtures/simple-integration-app/app.js @@ -1,8 +1,9 @@ var loopback = require('../../../'); +var boot = require('loopback-boot'); var path = require('path'); var app = module.exports = loopback(); -app.boot(__dirname); +boot(app, __dirname); app.use(loopback.favicon()); app.use(loopback.cookieParser({secret: app.get('cookieSecret')})); var apiPath = '/api'; diff --git a/test/hidden-properties.test.js b/test/hidden-properties.test.js index 356c20f7..d30d32f7 100644 --- a/test/hidden-properties.test.js +++ b/test/hidden-properties.test.js @@ -3,15 +3,20 @@ var loopback = require('../'); describe('hidden properties', function () { beforeEach(function (done) { var app = this.app = loopback(); - var Product = this.Product = app.model('product', { - options: {hidden: ['secret']}, - dataSource: loopback.memory() - }); - var Category = this.Category = this.app.model('category', { - dataSource: loopback.memory() - }); + var Product = this.Product = loopback.PersistedModel.extend('product', + {}, + {hidden: ['secret']} + ); + Product.attachTo(loopback.memory()); + + var Category = this.Category = loopback.PersistedModel.extend('category'); + Category.attachTo(loopback.memory()); Category.hasMany(Product); + + app.model(Product); + app.model(Category); app.use(loopback.rest()); + Category.create({ name: 'my category' }, function(err, category) { diff --git a/test/karma.conf.js b/test/karma.conf.js new file mode 100644 index 00000000..6b712904 --- /dev/null +++ b/test/karma.conf.js @@ -0,0 +1,98 @@ +// Karma configuration +// http://karma-runner.github.io/0.12/config/configuration-file.html + +module.exports = function(config) { + config.set({ + // enable / disable watching file and executing tests whenever any file changes + autoWatch: true, + + // base path, that will be used to resolve files and exclude + basePath: '../', + + // testing framework to use (jasmine/mocha/qunit/...) + frameworks: ['mocha', 'browserify'], + + // list of files / patterns to load in the browser + files: [ + 'node_modules/es5-shim/es5-shim.js', + 'test/support.js', + 'test/model.test.js', + 'test/geo-point.test.js', + 'test/app.test.js' + ], + + // list of files / patterns to exclude + exclude: [ + ], + + // test results reporter to use + // possible values: 'dots', 'progress', 'junit', 'growl', 'coverage' + reporters: ['dots'], + + // web server port + port: 9876, + + // cli runner port + runnerPort: 9100, + + // Start these browsers, currently available: + // - Chrome + // - ChromeCanary + // - Firefox + // - Opera + // - Safari (only Mac) + // - PhantomJS + // - IE (only Windows) + browsers: [ + 'Chrome' + ], + + // Which plugins to enable + plugins: [ + 'karma-browserify', + 'karma-mocha', + 'karma-phantomjs-launcher', + 'karma-chrome-launcher', + 'karma-junit-reporter' + ], + + // If browser does not capture in given timeout [ms], kill it + captureTimeout: 60000, + + // Continuous Integration mode + // if true, it capture browsers, run tests and exit + singleRun: false, + + colors: true, + + // level of logging + // possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG + logLevel: config.LOG_INFO, + + // Uncomment the following lines if you are using grunt's server to run the tests + // proxies: { + // '/': 'http://localhost:9000/' + // }, + // URL root prevent conflicts with the site root + // urlRoot: '_karma_' + + // Browserify config (all optional) + browserify: { + // extensions: ['.coffee'], + ignore: [ + 'nodemailer', + 'passport', + 'passport-local', + 'superagent', + 'supertest' + ], + // transform: ['coffeeify'], + debug: true, + // noParse: ['jquery'], + watch: true, + }, + + // Add browserify to preprocessors + preprocessors: {'test/*': ['browserify']} + }); +}; diff --git a/test/loopback.test.js b/test/loopback.test.js index b105b540..17f9bc5b 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -124,7 +124,7 @@ describe('loopback', function() { }); assert(loopback.getModel('MyModel') === MyModel); assert(loopback.getModel('MyCustomModel') === MyCustomModel); - assert(loopback.getModel('Invalid') === undefined); + assert(loopback.findModel('Invalid') === undefined); }); it('should be able to get model by type', function () { var MyModel = loopback.createModel('MyModel', {}, { @@ -141,6 +141,11 @@ describe('loopback', function() { assert(loopback.getModelByType(MyModel) === MyCustomModel); assert(loopback.getModelByType(MyCustomModel) === MyCustomModel); }); + + it('should throw when the model does not exist', function() { + expect(function() { loopback.getModel(uniqueModelName); }) + .to.throw(Error, new RegExp('Model not found: ' + uniqueModelName)); + }); }); }); diff --git a/test/model.test.js b/test/model.test.js index d1ed5097..3c544875 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -1,85 +1,42 @@ -require('./support'); -var ACL = require('../').ACL; +var async = require('async'); var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var PersistedModel = loopback.PersistedModel; -describe('Model', function() { +var describe = require('./util/describe'); - var User, memory; - - beforeEach(function () { - memory = loopback.createDataSource({connector: loopback.Memory}); - User = memory.createModel('user', { - 'first': String, - 'last': String, - 'age': Number, - 'password': String, - 'gender': String, - 'domain': String, - 'email': String - }); - }); - - describe('Model.validatesPresenceOf(properties...)', function() { - it("Require a model to include a property to be considered valid", function() { - User.validatesPresenceOf('first', 'last', 'age'); - var joe = new User({first: 'joe'}); - assert(joe.isValid() === false, 'model should not validate'); - assert(joe.errors.last, 'should have a missing last error'); - assert(joe.errors.age, 'should have a missing age error'); - }); - }); - - describe('Model.validatesLengthOf(property, options)', function() { - it("Require a property length to be within a specified range", function() { - User.validatesLengthOf('password', {min: 5, message: {min: 'Password is too short'}}); - var joe = new User({password: '1234'}); - assert(joe.isValid() === false, 'model should not be valid'); - assert(joe.errors.password, 'should have password error'); - }); - }); - - describe('Model.validatesInclusionOf(property, options)', function() { - it("Require a value for `property` to be in the specified array", function() { - User.validatesInclusionOf('gender', {in: ['male', 'female']}); - var foo = new User({gender: 'bar'}); - assert(foo.isValid() === false, 'model should not be valid'); - assert(foo.errors.gender, 'should have gender error'); - }); - }); - - describe('Model.validatesExclusionOf(property, options)', function() { - it("Require a value for `property` to not exist in the specified array", function() { - User.validatesExclusionOf('domain', {in: ['www', 'billing', 'admin']}); - var foo = new User({domain: 'www'}); - var bar = new User({domain: 'billing'}); - var bat = new User({domain: 'admin'}); - assert(foo.isValid() === false); - assert(bar.isValid() === false); - assert(bat.isValid() === false); - assert(foo.errors.domain, 'model should have a domain error'); - assert(bat.errors.domain, 'model should have a domain error'); - assert(bat.errors.domain, 'model should have a domain error'); - }); - }); - - describe('Model.validatesNumericalityOf(property, options)', function() { - it("Require a value for `property` to be a specific type of `Number`", function() { - User.validatesNumericalityOf('age', {int: true}); - var joe = new User({age: 10.2}); - assert(joe.isValid() === false); - var bob = new User({age: 0}); - assert(bob.isValid() === true); - assert(joe.errors.age, 'model should have an age error'); - }); +describe('Model / PersistedModel', function() { + defineModelTestsWithDataSource({ + dataSource: { + connector: loopback.Memory + } }); describe('Model.validatesUniquenessOf(property, options)', function() { it("Ensure the value for `property` is unique", function(done) { + var User = PersistedModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }); + + var dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + + User.attachTo(dataSource); + User.validatesUniquenessOf('email', {message: 'email is not unique'}); var joe = new User({email: 'joe@joe.com'}); var joe2 = new User({email: 'joe@joe.com'}); - + joe.save(function () { joe2.save(function (err) { assert(err, 'should get a validation error'); @@ -91,125 +48,72 @@ describe('Model', function() { }); }); - describe('myModel.isValid()', function() { - it("Validate the model instance", function() { - User.validatesNumericalityOf('age', {int: true}); - var user = new User({first: 'joe', age: 'flarg'}) - var valid = user.isValid(); - assert(valid === false); - assert(user.errors.age, 'model should have age error'); - }); - - it('Asynchronously validate the model', function(done) { - User.validatesNumericalityOf('age', {int: true}); - var user = new User({first: 'joe', age: 'flarg'}) - user.isValid(function (valid) { - assert(valid === false); - assert(user.errors.age, 'model should have age error'); - done(); - }); - }); - }); - describe('Model.attachTo(dataSource)', function() { it("Attach a model to a [DataSource](#data-source)", function() { var MyModel = loopback.createModel('my-model', {name: String}); + var dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); - assert(MyModel.find === undefined, 'should not have data access methods'); + MyModel.attachTo(dataSource); - MyModel.attachTo(memory); - - assert(typeof MyModel.find === 'function', 'should have data access methods after attaching to a data source'); - }); - }); - - describe('Model.create([data], [callback])', function() { - it("Create an instance of Model with given data and save to the attached data source", function(done) { - User.create({first: 'Joe', last: 'Bob'}, function(err, user) { - assert(user instanceof User); - done(); + MyModel.find(function(err, results) { + assert(results.length === 0, 'should have data access methods after attaching to a data source'); }); }); }); +}); - describe('model.save([options], [callback])', function() { - it("Save an instance of a Model to the attached data source", function(done) { - var joe = new User({first: 'Joe', last: 'Bob'}); - joe.save(function(err, user) { - assert(user.id); - assert(!err); - assert(!user.errors); - done(); - }); +describe.onServer('Remote Methods', function(){ + + var User; + var dataSource; + var app; + + beforeEach(function () { + User = PersistedModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true }); - }); - describe('model.updateAttributes(data, [callback])', function() { - it("Save specified attributes to the attached data source", function(done) { - User.create({first: 'joe', age: 100}, function (err, user) { - assert(!err); - assert.equal(user.first, 'joe'); - - user.updateAttributes({ - first: 'updatedFirst', - last: 'updatedLast' - }, function (err, updatedUser) { - assert(!err); - assert.equal(updatedUser.first, 'updatedFirst'); - assert.equal(updatedUser.last, 'updatedLast'); - assert.equal(updatedUser.age, 100); - done(); - }); - }); + dataSource = loopback.createDataSource({ + connector: loopback.Memory }); - }); - describe('Model.upsert(data, callback)', function() { - it("Update when record with id=data.id found, insert otherwise", function(done) { - User.upsert({first: 'joe', id: 7}, function (err, user) { - assert(!err); - assert.equal(user.first, 'joe'); - - User.upsert({first: 'bob', id: 7}, function (err, updatedUser) { - assert(!err); - assert.equal(updatedUser.first, 'bob'); - done(); - }); - }); - }); - }); + User.attachTo(dataSource); - describe('model.destroy([callback])', function() { - it("Remove a model from the attached data source", function(done) { - User.create({first: 'joe', last: 'bob'}, function (err, user) { - User.findById(user.id, function (err, foundUser) { - assert.equal(user.id, foundUser.id); - foundUser.destroy(function () { - User.findById(user.id, function (err, notFound) { - assert(!err); - assert.equal(notFound, null); - done(); - }); - }); - }); - }); - }); - }); + User.login = function (username, password, fn) { + if(username === 'foo' && password === 'bar') { + fn(null, 123); + } else { + throw new Error('bad username and password!'); + } + } - describe('Model.deleteById([callback])', function () { - it("Delete a model instance from the attached data source", function (done) { - User.create({first: 'joe', last: 'bob'}, function (err, user) { - User.deleteById(user.id, function (err) { - User.findById(user.id, function (err, notFound) { - assert(!err); - assert.equal(notFound, null); - done(); - }); - }); - }); - }); - }); + loopback.remoteMethod( + User.login, + { + accepts: [ + {arg: 'username', type: 'string', required: true}, + {arg: 'password', type: 'string', required: true} + ], + returns: {arg: 'sessionId', type: 'any', root: true}, + http: {path: '/sign-in', verb: 'get'} + } + ); + app = loopback(); + app.use(loopback.rest()); + app.model(User); + }); + describe('Model.destroyAll(callback)', function() { it("Delete all Model instances from data source", function(done) { (new TaskEmitter()) @@ -220,7 +124,6 @@ describe('Model', function() { .task(User, 'create', {first: 'suzy'}) .on('done', function () { User.count(function (err, count) { - assert.equal(count, 5); User.destroyAll(function () { User.count(function (err, count) { assert.equal(count, 0); @@ -232,93 +135,97 @@ describe('Model', function() { }); }); - describe('Model.findById(id, callback)', function() { - it("Find an instance by id", function(done) { - User.create({first: 'michael', last: 'jordan', id: 23}, function () { - User.findById(23, function (err, user) { - assert.equal(user.id, 23); - assert.equal(user.first, 'michael'); - assert.equal(user.last, 'jordan'); + describe('Example Remote Method', function () { + it('Call the method using HTTP / REST', function(done) { + request(app) + .get('/users/sign-in?username=foo&password=bar') + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res){ + if(err) return done(err); + assert.equal(res.body, 123); done(); }); - }); + }); + + it('Converts null result of findById to 404 Not Found', function(done) { + request(app) + .get('/users/not-found') + .expect(404) + .end(done); }); }); - describe('Model.count([query], callback)', function() { - it("Query count of Model instances in data source", function(done) { - (new TaskEmitter()) - .task(User, 'create', {first: 'jill', age: 100}) - .task(User, 'create', {first: 'bob', age: 200}) - .task(User, 'create', {first: 'jan'}) - .task(User, 'create', {first: 'sam'}) - .task(User, 'create', {first: 'suzy'}) - .on('done', function () { - User.count({age: {gt: 99}}, function (err, count) { - assert.equal(count, 2); - done(); - }); + describe('Model.beforeRemote(name, fn)', function(){ + it('Run a function before a remote method is called by a client', function(done) { + var hookCalled = false; + + User.beforeRemote('create', function(ctx, user, next) { + hookCalled = true; + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(hookCalled, 'hook wasnt called'); + done(); }); }); }); - describe.onServer('Remote Methods', function(){ - - beforeEach(function () { - User.login = function (username, password, fn) { - if(username === 'foo' && password === 'bar') { - fn(null, 123); - } else { - throw new Error('bad username and password!'); - } - } - - loopback.remoteMethod( - User.login, - { - accepts: [ - {arg: 'username', type: 'string', required: true}, - {arg: 'password', type: 'string', required: true} - ], - returns: {arg: 'sessionId', type: 'any', root: true}, - http: {path: '/sign-in', verb: 'get'} - } - ); + describe('Model.afterRemote(name, fn)', function(){ + it('Run a function after a remote method is called by a client', function(done) { + var beforeCalled = false; + var afterCalled = false; - app.use(loopback.rest()); - app.model(User); - }); - - describe('Example Remote Method', function () { - it('Call the method using HTTP / REST', function(done) { - request(app) - .get('/users/sign-in?username=foo&password=bar') - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res){ - if(err) return done(err); - assert.equal(res.body, 123); - done(); - }); + User.beforeRemote('create', function(ctx, user, next) { + assert(!afterCalled); + beforeCalled = true; + next(); }); + User.afterRemote('create', function(ctx, user, next) { + assert(beforeCalled); + afterCalled = true; + next(); + }); + + // invoke save + request(app) + .post('/users') + .send({data: {first: 'foo', last: 'bar'}}) + .expect('Content-Type', /json/) + .expect(200) + .end(function(err, res) { + if(err) return done(err); + assert(beforeCalled, 'before hook was not called'); + assert(afterCalled, 'after hook was not called'); + done(); + }); + }); + }); - it('Converts null result of findById to 404 Not Found', function(done) { - request(app) - .get('/users/not-found') - .expect(404) - .end(done); - }); - }); - - describe('Model.beforeRemote(name, fn)', function(){ - it('Run a function before a remote method is called by a client', function(done) { + describe('Remote Method invoking context', function () { + describe('ctx.req', function() { + it("The express ServerRequest object", function(done) { var hookCalled = false; User.beforeRemote('create', function(ctx, user, next) { hookCalled = true; + assert(ctx.req); + assert(ctx.req.url); + assert(ctx.req.method); + assert(ctx.res); + assert(ctx.res.write); + assert(ctx.res.end); next(); }); - + // invoke save request(app) .post('/users') @@ -327,28 +234,27 @@ describe('Model', function() { .expect(200) .end(function(err, res) { if(err) return done(err); - assert(hookCalled, 'hook wasnt called'); + assert(hookCalled); done(); }); }); }); - - describe('Model.afterRemote(name, fn)', function(){ - it('Run a function after a remote method is called by a client', function(done) { - var beforeCalled = false; - var afterCalled = false; + + describe('ctx.res', function() { + it("The express ServerResponse object", function(done) { + var hookCalled = false; User.beforeRemote('create', function(ctx, user, next) { - assert(!afterCalled); - beforeCalled = true; + hookCalled = true; + assert(ctx.req); + assert(ctx.req.url); + assert(ctx.req.method); + assert(ctx.res); + assert(ctx.res.write); + assert(ctx.res.end); next(); }); - User.afterRemote('create', function(ctx, user, next) { - assert(beforeCalled); - afterCalled = true; - next(); - }); - + // invoke save request(app) .post('/users') @@ -357,115 +263,17 @@ describe('Model', function() { .expect(200) .end(function(err, res) { if(err) return done(err); - assert(beforeCalled, 'before hook was not called'); - assert(afterCalled, 'after hook was not called'); + assert(hookCalled); done(); }); }); }); - - describe('Remote Method invoking context', function () { - // describe('ctx.user', function() { - // it("The remote user model calling the method remotely", function(done) { - // done(new Error('test not implemented')); - // }); - // }); - - describe('ctx.req', function() { - it("The express ServerRequest object", function(done) { - var hookCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - hookCalled = true; - assert(ctx.req); - assert(ctx.req.url); - assert(ctx.req.method); - assert(ctx.res); - assert(ctx.res.write); - assert(ctx.res.end); - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(hookCalled); - done(); - }); - }); - }); - - describe('ctx.res', function() { - it("The express ServerResponse object", function(done) { - var hookCalled = false; - - User.beforeRemote('create', function(ctx, user, next) { - hookCalled = true; - assert(ctx.req); - assert(ctx.req.url); - assert(ctx.req.method); - assert(ctx.res); - assert(ctx.res.write); - assert(ctx.res.end); - next(); - }); - - // invoke save - request(app) - .post('/users') - .send({data: {first: 'foo', last: 'bar'}}) - .expect('Content-Type', /json/) - .expect(200) - .end(function(err, res) { - if(err) return done(err); - assert(hookCalled); - done(); - }); - }); - }); - }) - - describe('in compat mode', function() { - before(function() { - loopback.compat.usePluralNamesForRemoting = true; - }); - after(function() { - loopback.compat.usePluralNamesForRemoting = false; - }); - - it('correctly install before/after hooks', function(done) { - var hooksCalled = []; - - User.beforeRemote('**', function(ctx, user, next) { - hooksCalled.push('beforeRemote'); - next(); - }); - - User.afterRemote('**', function(ctx, user, next) { - hooksCalled.push('afterRemote'); - next(); - }); - - request(app).get('/users') - .expect(200, function(err, res) { - if (err) return done(err); - expect(hooksCalled, 'hooks called') - .to.eql(['beforeRemote', 'afterRemote']); - done(); - }); - }); - }); - }); + }) describe('Model.hasMany(Model)', function() { it("Define a one to many relationship", function(done) { - var Book = memory.createModel('book', {title: String, author: String}); - var Chapter = memory.createModel('chapter', {title: String}); + var Book = dataSource.createModel('book', {title: String, author: String}); + var Chapter = dataSource.createModel('chapter', {title: String}); // by referencing model Book.hasMany(Chapter); @@ -523,7 +331,7 @@ describe('Model', function() { describe('Model.extend()', function(){ it('Create a new model by extending an existing model', function() { - var User = loopback.Model.extend('test-user', { + var User = loopback.PersistedModel.extend('test-user', { email: String }); @@ -557,33 +365,33 @@ describe('Model', function() { describe('Model.extend() events', function() { it('create isolated emitters for subclasses', function() { - var User1 = loopback.createModel('User1', { - 'first': String, - 'last': String - }); + var User1 = loopback.createModel('User1', { + 'first': String, + 'last': String + }); - var User2 = loopback.createModel('User2', { - 'name': String - }); + var User2 = loopback.createModel('User2', { + 'name': String + }); - var user1Triggered = false; - User1.once('x', function(event) { - user1Triggered = true; - }); + var user1Triggered = false; + User1.once('x', function(event) { + user1Triggered = true; + }); - var user2Triggered = false; - User2.once('x', function(event) { - user2Triggered = true; - }); + var user2Triggered = false; + User2.once('x', function(event) { + user2Triggered = true; + }); - assert(User1.once !== User2.once); - assert(User1.once !== loopback.Model.once); + assert(User1.once !== User2.once); + assert(User1.once !== loopback.Model.once); - User1.emit('x', User1); + User1.emit('x', User1); - assert(user1Triggered); - assert(!user2Triggered); + assert(user1Triggered); + assert(!user2Triggered); }); }); @@ -615,6 +423,56 @@ describe('Model', function() { } }); + describe('Model.getChangeModel()', function() { + it('Get the Change Model', function () { + var UserChange = User.getChangeModel(); + var change = new UserChange(); + assert(change instanceof Change); + }); + }); + + describe('Model.getSourceId(callback)', function() { + it('Get the Source Id', function (done) { + User.getSourceId(function(err, id) { + assert.equal('memory-user', id); + done(); + }); + }); + }); + + describe('Model.checkpoint(callback)', function() { + it('Create a checkpoint', function (done) { + var Checkpoint = User.getChangeModel().getCheckpointModel(); + var tasks = [ + getCurrentCheckpoint, + checkpoint + ]; + var result; + var current; + + async.series(tasks, function(err) { + if(err) return done(err); + + assert.equal(result, current + 1); + done(); + }); + + function getCurrentCheckpoint(cb) { + Checkpoint.current(function(err, cp) { + current = cp; + cb(err); + }); + } + + function checkpoint(cb) { + User.checkpoint(function(err, cp) { + result = cp.seq; + cb(err); + }); + } + }); + }); + describe('Model._getACLModel()', function() { it('should return the subclass of ACL', function() { var Model = require('../').Model; diff --git a/test/relations.integration.js b/test/relations.integration.js index 336fb7eb..84a15a6b 100644 --- a/test/relations.integration.js +++ b/test/relations.integration.js @@ -378,7 +378,6 @@ describe('relations - integration', function () { it.skip('allows to find related objects via where filter', function(done) { //TODO https://github.com/strongloop/loopback-datasource-juggler/issues/94 var expectedProduct = this.product; - // Note: the URL format is not final this.get('/api/products?filter[where][categoryId]=' + this.category.id) .expect(200, function(err, res) { if (err) return done(err); diff --git a/test/remote-connector.test.js b/test/remote-connector.test.js index 2a55c02c..b0b31135 100644 --- a/test/remote-connector.test.js +++ b/test/remote-connector.test.js @@ -1,42 +1,70 @@ var loopback = require('../'); +var defineModelTestsWithDataSource = require('./util/model-tests'); describe('RemoteConnector', function() { + var remoteApp; + var remote; + + defineModelTestsWithDataSource({ + beforeEach: function(done) { + var test = this; + remoteApp = loopback(); + remoteApp.use(loopback.rest()); + remoteApp.listen(0, function() { + test.dataSource = loopback.createDataSource({ + host: remoteApp.get('host'), + port: remoteApp.get('port'), + connector: loopback.Remote + }); + done(); + }); + }, + onDefine: function(Model) { + var RemoteModel = Model.extend(Model.modelName); + RemoteModel.attachTo(loopback.createDataSource({ + connector: loopback.Memory + })); + remoteApp.model(RemoteModel); + } + }); + beforeEach(function(done) { - var LocalModel = this.LocalModel = loopback.DataModel.extend('LocalModel'); - var RemoteModel = loopback.DataModel.extend('LocalModel'); - var localApp = loopback(); - var remoteApp = loopback(); - localApp.model(LocalModel); - remoteApp.model(RemoteModel); + var test = this; + remoteApp = this.remoteApp = loopback(); remoteApp.use(loopback.rest()); - RemoteModel.attachTo(loopback.memory()); + var ServerModel = this.ServerModel = loopback.PersistedModel.extend('TestModel'); + + remoteApp.model(ServerModel); + remoteApp.listen(0, function() { - var ds = loopback.createDataSource({ + test.remote = loopback.createDataSource({ host: remoteApp.get('host'), port: remoteApp.get('port'), connector: loopback.Remote }); - - LocalModel.attachTo(ds); done(); }); }); - it('should alow methods to be called remotely', function (done) { - var data = {foo: 'bar'}; - this.LocalModel.create(data, function(err, result) { - if(err) return done(err); - expect(result).to.deep.equal({id: 1, foo: 'bar'}); - done(); - }); - }); + it('should support the save method', function (done) { + var calledServerCreate = false; + var RemoteModel = loopback.PersistedModel.extend('TestModel'); + RemoteModel.attachTo(this.remote); - it('should alow instance methods to be called remotely', function (done) { - var data = {foo: 'bar'}; - var m = new this.LocalModel(data); - m.save(function(err, result) { - if(err) return done(err); - expect(result).to.deep.equal({id: 2, foo: 'bar'}); + var ServerModel = this.ServerModel; + + ServerModel.create = function(data, cb) { + calledServerCreate = true; + data.id = 1; + cb(null, data); + } + + ServerModel.setupRemoting(); + + var m = new RemoteModel({foo: 'bar'}); + m.save(function(err, inst) { + assert(inst instanceof RemoteModel); + assert(calledServerCreate); done(); }); }); diff --git a/test/remoting.integration.js b/test/remoting.integration.js index b6d2b286..aa017079 100644 --- a/test/remoting.integration.js +++ b/test/remoting.integration.js @@ -18,7 +18,7 @@ describe('remoting - integration', function () { it("should load remoting options", function () { var remotes = app.remotes(); assert.deepEqual(remotes.options, {"json": {"limit": "1kb", "strict": false}, - "urlencoded": {"limit": "8kb"}}); + "urlencoded": {"limit": "8kb", "extended": true}}); }); it("rest handler", function () { diff --git a/test/replication.test.js b/test/replication.test.js new file mode 100644 index 00000000..286535b3 --- /dev/null +++ b/test/replication.test.js @@ -0,0 +1,343 @@ +var async = require('async'); +var loopback = require('../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var defineModelTestsWithDataSource = require('./util/model-tests'); +var PersistedModel = loopback.PersistedModel; + +describe('Replication / Change APIs', function() { + beforeEach(function() { + var test = this; + var dataSource = this.dataSource = loopback.createDataSource({ + connector: loopback.Memory + }); + var SourceModel = this.SourceModel = PersistedModel.extend('SourceModel', {}, { + trackChanges: true + }); + SourceModel.attachTo(dataSource); + + var TargetModel = this.TargetModel = PersistedModel.extend('TargetModel', {}, { + trackChanges: true + }); + TargetModel.attachTo(dataSource); + + this.createInitalData = function(cb) { + SourceModel.create({name: 'foo'}, function(err, inst) { + if(err) return cb(err); + test.model = inst; + + // give loopback a chance to register the change + // TODO(ritch) get rid of this... + setTimeout(function() { + SourceModel.replicate(TargetModel, cb); + }, 100); + }); + }; + }); + + describe('Model.changes(since, filter, callback)', function() { + it('Get changes since the given checkpoint', function (done) { + var test = this; + this.SourceModel.create({name: 'foo'}, function(err) { + if(err) return done(err); + setTimeout(function() { + test.SourceModel.changes(test.startingCheckpoint, {}, function(err, changes) { + assert.equal(changes.length, 1); + done(); + }); + }, 1); + }); + }); + }); + + describe('Model.replicate(since, targetModel, options, callback)', function() { + it('Replicate data using the target model', function (done) { + var test = this; + var options = {}; + var sourceData; + var targetData; + + this.SourceModel.create({name: 'foo'}, function(err) { + setTimeout(replicate, 100); + }); + + function replicate() { + test.SourceModel.replicate(test.startingCheckpoint, test.TargetModel, + options, function(err, conflicts) { + assert(conflicts.length === 0); + async.parallel([ + function(cb) { + test.SourceModel.find(function(err, result) { + if(err) return cb(err); + sourceData = result; + cb(); + }); + }, + function(cb) { + test.TargetModel.find(function(err, result) { + if(err) return cb(err); + targetData = result; + cb(); + }); + } + ], function(err) { + if(err) return done(err); + + assert.deepEqual(sourceData, targetData); + done(); + }); + }); + } + }); + }); + + describe('conflict detection - both updated', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict(err, conflicts) { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be UPDATE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.UPDATE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - source deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.name = 'target update'; + inst.save(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.DELETE); + assert.equal(targetChange.type(), Change.UPDATE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(source, null); + assert.deepEqual(target.toJSON(), { + id: 1, + name: 'target update' + }); + done(); + }); + }); + }); + + describe('conflict detection - target deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.name = 'source update'; + inst.save(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should detect a single conflict', function() { + assert.equal(this.conflicts.length, 1); + assert(this.conflict); + }); + it('type should be DELETE', function(done) { + this.conflict.type(function(err, type) { + assert.equal(type, Change.DELETE); + done(); + }); + }); + it('conflict.changes()', function(done) { + var test = this; + this.conflict.changes(function(err, sourceChange, targetChange) { + assert.equal(typeof sourceChange.id, 'string'); + assert.equal(typeof targetChange.id, 'string'); + assert.equal(test.model.getId(), sourceChange.getModelId()); + assert.equal(sourceChange.type(), Change.UPDATE); + assert.equal(targetChange.type(), Change.DELETE); + done(); + }); + }); + it('conflict.models()', function(done) { + var test = this; + this.conflict.models(function(err, source, target) { + assert.equal(target, null); + assert.deepEqual(source.toJSON(), { + id: 1, + name: 'source update' + }); + done(); + }); + }); + }); + + describe('conflict detection - both deleted', function() { + beforeEach(function(done) { + var SourceModel = this.SourceModel; + var TargetModel = this.TargetModel; + var test = this; + + test.createInitalData(createConflict); + + function createConflict() { + async.parallel([ + function(cb) { + SourceModel.findOne(function(err, inst) { + if(err) return cb(err); + test.model = inst; + inst.remove(cb); + }); + }, + function(cb) { + TargetModel.findOne(function(err, inst) { + if(err) return cb(err); + inst.remove(cb); + }); + } + ], function(err) { + if(err) return done(err); + SourceModel.replicate(TargetModel, function(err, conflicts) { + if(err) return done(err); + test.conflicts = conflicts; + test.conflict = conflicts[0]; + done(); + }); + }); + } + }); + it('should not detect a conflict', function() { + assert.equal(this.conflicts.length, 0); + assert(!this.conflict); + }); + }); +}); diff --git a/test/support.js b/test/support.js index 6737119a..81d420e1 100644 --- a/test/support.js +++ b/test/support.js @@ -10,6 +10,7 @@ GeoPoint = loopback.GeoPoint; app = null; TaskEmitter = require('strong-task-emitter'); request = require('supertest'); +var RemoteObjects = require('strong-remoting'); // Speed up the password hashing algorithm // for tests using the built-in User model @@ -49,19 +50,3 @@ assert.isFunc = function (obj, name) { assert(obj, 'cannot assert function ' + name + ' on object that doesnt exist'); assert(typeof obj[name] === 'function', name + ' is not a function'); } - -describe.onServer = function describeOnServer(name, fn) { - if (loopback.isServer) { - describe(name, fn); - } else { - describe.skip(name, fn); - } -}; - -describe.inBrowser = function describeInBrowser(name, fn) { - if (loopback.isBrowser) { - describe(name, fn); - } else { - describe.skip(name, fn); - } -}; \ No newline at end of file diff --git a/test/user.test.js b/test/user.test.js index 1b6785c8..b7588779 100644 --- a/test/user.test.js +++ b/test/user.test.js @@ -109,6 +109,15 @@ describe('User', function(){ }); }); }); + + it('Requires a unique username', function(done) { + User.create({email: 'a@b.com', username: 'abc', password: 'foobar'}, function () { + User.create({email: 'b@b.com', username: 'abc', password: 'batbaz'}, function (err) { + assert(err, 'should error because the username is not unique!'); + done(); + }); + }); + }); it('Requires a password to login with basic auth', function(done) { User.create({email: 'b@c.com'}, function (err) { @@ -443,9 +452,9 @@ describe('User', function(){ user.verify(options, function (err, result) { assert(result.email); - assert(result.email.message); + assert(result.email.response); assert(result.token); - assert(~result.email.message.indexOf('To: bar@bat.com')); + assert(~result.email.response.toString('utf-8').indexOf('To: bar@bat.com')); done(); }); }); diff --git a/test/util/describe.js b/test/util/describe.js new file mode 100644 index 00000000..db712113 --- /dev/null +++ b/test/util/describe.js @@ -0,0 +1,19 @@ +var loopback = require('../../'); + +module.exports = describe; + +describe.onServer = function describeOnServer(name, fn) { + if (loopback.isServer) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; + +describe.inBrowser = function describeInBrowser(name, fn) { + if (loopback.isBrowser) { + describe(name, fn); + } else { + describe.skip(name, fn); + } +}; diff --git a/test/util/it.js b/test/util/it.js new file mode 100644 index 00000000..f1b004e2 --- /dev/null +++ b/test/util/it.js @@ -0,0 +1,19 @@ +var loopback = require('../../'); + +module.exports = it; + +it.onServer = function itOnServer(name, fn) { + if (loopback.isServer) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; + +it.inBrowser = function itInBrowser(name, fn) { + if (loopback.isBrowser) { + it(name, fn); + } else { + it.skip(name, fn); + } +}; diff --git a/test/util/model-tests.js b/test/util/model-tests.js new file mode 100644 index 00000000..58a5c0c2 --- /dev/null +++ b/test/util/model-tests.js @@ -0,0 +1,247 @@ +var async = require('async'); +var describe = require('./describe'); +var loopback = require('../../'); +var ACL = loopback.ACL; +var Change = loopback.Change; +var PersistedModel = loopback.PersistedModel; +var RemoteObjects = require('strong-remoting'); + +module.exports = function defineModelTestsWithDataSource(options) { + +describe('Model Tests', function() { + + var User, dataSource; + + if(options.beforeEach) { + beforeEach(options.beforeEach); + } + + beforeEach(function() { + var test = this; + + // setup a model / datasource + dataSource = this.dataSource || loopback.createDataSource(options.dataSource); + + var extend = PersistedModel.extend; + + // create model hook + PersistedModel.extend = function() { + var extendedModel = extend.apply(PersistedModel, arguments); + + if(options.onDefine) { + options.onDefine.call(test, extendedModel); + } + + return extendedModel; + } + + User = PersistedModel.extend('user', { + 'first': String, + 'last': String, + 'age': Number, + 'password': String, + 'gender': String, + 'domain': String, + 'email': String + }, { + trackChanges: true + }); + + // enable destroy all for testing + User.destroyAll.shared = true; + User.attachTo(dataSource); + }); + + describe('Model.validatesPresenceOf(properties...)', function() { + it("Require a model to include a property to be considered valid", function() { + User.validatesPresenceOf('first', 'last', 'age'); + var joe = new User({first: 'joe'}); + assert(joe.isValid() === false, 'model should not validate'); + assert(joe.errors.last, 'should have a missing last error'); + assert(joe.errors.age, 'should have a missing age error'); + }); + }); + + describe('Model.validatesLengthOf(property, options)', function() { + it("Require a property length to be within a specified range", function() { + User.validatesLengthOf('password', {min: 5, message: {min: 'Password is too short'}}); + var joe = new User({password: '1234'}); + assert(joe.isValid() === false, 'model should not be valid'); + assert(joe.errors.password, 'should have password error'); + }); + }); + + describe('Model.validatesInclusionOf(property, options)', function() { + it("Require a value for `property` to be in the specified array", function() { + User.validatesInclusionOf('gender', {in: ['male', 'female']}); + var foo = new User({gender: 'bar'}); + assert(foo.isValid() === false, 'model should not be valid'); + assert(foo.errors.gender, 'should have gender error'); + }); + }); + + describe('Model.validatesExclusionOf(property, options)', function() { + it("Require a value for `property` to not exist in the specified array", function() { + User.validatesExclusionOf('domain', {in: ['www', 'billing', 'admin']}); + var foo = new User({domain: 'www'}); + var bar = new User({domain: 'billing'}); + var bat = new User({domain: 'admin'}); + assert(foo.isValid() === false); + assert(bar.isValid() === false); + assert(bat.isValid() === false); + assert(foo.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + assert(bat.errors.domain, 'model should have a domain error'); + }); + }); + + describe('Model.validatesNumericalityOf(property, options)', function() { + it("Require a value for `property` to be a specific type of `Number`", function() { + User.validatesNumericalityOf('age', {int: true}); + var joe = new User({age: 10.2}); + assert(joe.isValid() === false); + var bob = new User({age: 0}); + assert(bob.isValid() === true); + assert(joe.errors.age, 'model should have an age error'); + }); + }); + + describe('myModel.isValid()', function() { + it("Validate the model instance", function() { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}) + var valid = user.isValid(); + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + }); + + it('Asynchronously validate the model', function(done) { + User.validatesNumericalityOf('age', {int: true}); + var user = new User({first: 'joe', age: 'flarg'}); + user.isValid(function (valid) { + assert(valid === false); + assert(user.errors.age, 'model should have age error'); + done(); + }); + }); + }); + + describe('Model.create([data], [callback])', function() { + it("Create an instance of Model with given data and save to the attached data source", function(done) { + User.create({first: 'Joe', last: 'Bob'}, function(err, user) { + assert(user instanceof User); + done(); + }); + }); + }); + + describe('model.save([options], [callback])', function() { + it("Save an instance of a Model to the attached data source", function(done) { + var joe = new User({first: 'Joe', last: 'Bob'}); + joe.save(function(err, user) { + assert(user.id); + assert(!err); + assert(!user.errors); + done(); + }); + }); + }); + + describe('model.updateAttributes(data, [callback])', function() { + it("Save specified attributes to the attached data source", function(done) { + User.create({first: 'joe', age: 100}, function (err, user) { + assert(!err); + assert.equal(user.first, 'joe'); + + user.updateAttributes({ + first: 'updatedFirst', + last: 'updatedLast' + }, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'updatedFirst'); + assert.equal(updatedUser.last, 'updatedLast'); + assert.equal(updatedUser.age, 100); + done(); + }); + }); + }); + }); + + describe('Model.upsert(data, callback)', function() { + it("Update when record with id=data.id found, insert otherwise", function(done) { + User.upsert({first: 'joe', id: 7}, function (err, user) { + assert(!err); + assert.equal(user.first, 'joe'); + + User.upsert({first: 'bob', id: 7}, function (err, updatedUser) { + assert(!err); + assert.equal(updatedUser.first, 'bob'); + done(); + }); + }); + }); + }); + + describe('model.destroy([callback])', function() { + it("Remove a model from the attached data source", function(done) { + User.create({first: 'joe', last: 'bob'}, function (err, user) { + User.findById(user.id, function (err, foundUser) { + assert.equal(user.id, foundUser.id); + foundUser.destroy(function () { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + }); + + describe('Model.deleteById(id, [callback])', function () { + it("Delete a model instance from the attached data source", function (done) { + User.create({first: 'joe', last: 'bob'}, function (err, user) { + User.deleteById(user.id, function (err) { + User.findById(user.id, function (err, notFound) { + assert.equal(notFound, null); + done(); + }); + }); + }); + }); + }); + + describe('Model.findById(id, callback)', function() { + it("Find an instance by id", function(done) { + User.create({first: 'michael', last: 'jordan', id: 23}, function () { + User.findById(23, function (err, user) { + assert.equal(user.id, 23); + assert.equal(user.first, 'michael'); + assert.equal(user.last, 'jordan'); + done(); + }); + }); + }); + }); + + describe('Model.count([query], callback)', function() { + it("Query count of Model instances in data source", function(done) { + (new TaskEmitter()) + .task(User, 'create', {first: 'jill', age: 100}) + .task(User, 'create', {first: 'bob', age: 200}) + .task(User, 'create', {first: 'jan'}) + .task(User, 'create', {first: 'sam'}) + .task(User, 'create', {first: 'suzy'}) + .on('done', function () { + User.count({age: {gt: 99}}, function (err, count) { + assert.equal(count, 2); + done(); + }); + }); + }); + }); + +}); + + +}