Merge branch 'master' into feature/fix-issue-377

This commit is contained in:
Raymond Feng 2014-07-22 10:49:20 -07:00
commit 335bae4b46
52 changed files with 3778 additions and 1917 deletions

2
.gitignore vendored
View File

@ -11,3 +11,5 @@
*.swp
*.swo
node_modules
dist
*xunit.xml

35
CHANGES.md Normal file
View File

@ -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.

View File

@ -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']);
};

View File

@ -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"
}

View File

@ -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'}

138
example/replication/app.js Normal file
View File

@ -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);
}

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

View File

@ -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.
*
* <a name="model-definition"></a>
* **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.
*

View File

@ -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;
};

View File

@ -51,4 +51,4 @@ Connector._createJDBAdapter = function (jdbModule) {
Connector.prototype._addCrudOperationsFromJDBAdapter = function (connector) {
}
}

View File

@ -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);
}

View File

@ -36,4 +36,4 @@ inherits(Memory, Connector);
* JugglingDB Compatibility
*/
Memory.initialize = JdbMemory.initialize;
Memory.initialize = JdbMemory.initialize;

View File

@ -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() {}

53
lib/express-middleware.js Normal file
View File

@ -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);
};

View File

@ -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;

View File

@ -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);

View File

@ -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

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

@ -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);
});
}

77
lib/models/checkpoint.js Normal file
View File

@ -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();
}
}

View File

@ -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';
}
}

View File

@ -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

View File

@ -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();

View File

@ -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;
}

View File

@ -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;

View File

@ -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",

View File

@ -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);

View File

@ -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);

View File

@ -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);

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

@ -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();
});
});
});
});

24
test/checkpoint.test.js Normal file
View File

@ -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);
});
});
});

View File

@ -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');
}
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});
});
});
});

View File

@ -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);

View File

@ -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();

View File

@ -123,7 +123,7 @@
"permission": "DENY",
"principalType": "ROLE",
"principalId": "$owner",
"property": "removeById"
"property": "deleteById"
}
]
},

View File

@ -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());

View File

@ -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
});

View File

@ -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';

View File

@ -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) {

98
test/karma.conf.js Normal file
View File

@ -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']}
});
};

View File

@ -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));
});
});
});

View File

@ -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;

View File

@ -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);

View File

@ -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();
});
});

View File

@ -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 () {

343
test/replication.test.js Normal file
View File

@ -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);
});
});
});

View File

@ -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);
}
};

View File

@ -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();
});
});

19
test/util/describe.js Normal file
View File

@ -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);
}
};

19
test/util/it.js Normal file
View File

@ -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);
}
};

247
test/util/model-tests.js Normal file
View File

@ -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();
});
});
});
});
});
}