diff --git a/lib/application.js b/lib/application.js index 4059de2b..4197d102 100644 --- a/lib/application.js +++ b/lib/application.js @@ -3,7 +3,7 @@ */ var DataSource = require('loopback-datasource-juggler').DataSource - , ModelBuilder = require('loopback-datasource-juggler').ModelBuilder + , loopback = require('../') , compat = require('./compat') , assert = require('assert') , fs = require('fs') @@ -82,33 +82,40 @@ app.disuse = function (route) { } /** - * Define and attach a model to the app. The `Model` will be available on the + * Attach a model to the app. The `Model` will be available on the * `app.models` object. * * ```js - * var Widget = app.model('Widget', {dataSource: 'db'}); - * Widget.create({name: 'pencil'}); - * app.models.Widget.find(function(err, widgets) { - * console.log(widgets[0]); // => {name: 'pencil'} + * // Attach an existing model + * var User = loopback.User; + * app.model(User); + * + * // Attach an existing model, alter some aspects of the model + * var User = loopback.User; + * app.model(User, { dataSource: 'db' }); + * + * // LoopBack 1.x way: create and attach a new model (deprecated) + * var Widget = app.model('Widget', { + * dataSource: 'db', + * properties: { + * name: 'string' + * } * }); * ``` * - * @param {String} modelName The name of the model to define. + * @param {Object|String} Model The model to attach. * @options {Object} config The model's configuration. - * @property {String|DataSource} dataSource The `DataSource` to which to attach the model. - * @property {Object} [options] an object containing `Model` options. - * @property {ACL[]} [options.acls] an array of `ACL` definitions. - * @property {String[]} [options.hidden] **experimental** an array of properties to hide when accessed remotely. - * @property {Object} [properties] object defining the `Model` properties in [LoopBack Definition Language](http://docs.strongloop.com/loopback-datasource-juggler/#loopback-definition-language). + * @property {String|DataSource} dataSource The `DataSource` to which to + * attach the model. + * @property {Boolean} [public] whether the model should be exposed via REST API + * @property {Object} [relations] relations to add/update * @end * @returns {ModelConstructor} the model class */ app.model = function (Model, config) { if(arguments.length === 1) { - assert(typeof Model === 'function', 'app.model(Model) => Model must be a function / constructor'); - assert(Model.modelName, 'Model must have a "modelName" property'); - var remotingClassName = compat.getClassNameForRemoting(Model); + assertIsModel(Model); if(Model.sharedClass) { this.remotes().addClass(Model.sharedClass); } @@ -117,22 +124,53 @@ app.model = function (Model, config) { Model.shared = true; Model.app = this; Model.emit('attached', this); - return; + return Model; } - var modelName = Model; - config = config || {}; - assert(typeof modelName === 'string', 'app.model(name, config) => "name" name must be a string'); - Model = + config = config || {}; + + if (typeof Model === 'string') { + // create & attach the model - loopback 1.x compatibility + + // create config for loopback.modelFromConfig + var modelConfig = extend({}, config); + modelConfig.options = extend({}, config.options); + modelConfig.name = Model; + + // modeller does not understand `dataSource` option + delete modelConfig.dataSource; + + Model = loopback.createModelFromConfig(modelConfig); + + // delete config options already applied + ['relations', 'base', 'acls', 'hidden'].forEach(function(prop) { + delete config[prop]; + if (config.options) delete config.options[prop]; + }); + delete config.properties; + } + + configureModel(Model, config, this); + + var modelName = Model.modelName; this.models[modelName] = this.models[classify(modelName)] = - this.models[camelize(modelName)] = modelFromConfig(modelName, config, this); + this.models[camelize(modelName)] = Model; - if(config.public !== false) { + if (config.public !== false) { this.model(Model); } return Model; +}; + + +function assertIsModel(Model) { + assert(typeof Model === 'function', + 'Model must be a function / constructor'); + assert(Model.modelName, 'Model must have a "modelName" property'); + assert(Model.prototype instanceof loopback.Model, + 'Model must be a descendant of loopback.Model'); } /** @@ -566,41 +604,23 @@ function dataSourcesFromConfig(config, connectorRegistry) { return require('./loopback').createDataSource(config); } -function modelFromConfig(name, config, app) { - var options = buildModelOptionsFromConfig(config); - var properties = config.properties; +function configureModel(ModelCtor, config, app) { + assertIsModel(ModelCtor); - var ModelCtor = require('./loopback').createModel(name, properties, options); var dataSource = config.dataSource; if(typeof dataSource === 'string') { dataSource = app.dataSources[dataSource]; } - assert(isDataSource(dataSource), name + ' is referencing a dataSource that does not exist: "'+ config.dataSource +'"'); + assert(isDataSource(dataSource), + ModelCtor.modelName + ' is referencing a dataSource that does not exist: "' + + config.dataSource +'"'); - ModelCtor.attachTo(dataSource); - return ModelCtor; -} + config = extend({}, config); + config.dataSource = dataSource; -function buildModelOptionsFromConfig(config) { - var options = extend({}, config.options); - for (var key in config) { - if (['properties', 'options', 'dataSource'].indexOf(key) !== -1) { - // Skip items which have special meaning - continue; - } - - if (options[key] !== undefined) { - // When both `config.key` and `config.options.key` are set, - // use the latter one to preserve backwards compatibility - // with loopback 1.x - continue; - } - - options[key] = config[key]; - } - return options; + loopback.configureModel(ModelCtor, config); } function requireDir(dir, basenames) { @@ -672,10 +692,6 @@ function tryReadDir() { } } -function isModelCtor(obj) { - return typeof obj === 'function' && obj.modelName && obj.name === 'ModelCtor'; -} - function isDataSource(obj) { return obj instanceof DataSource; } diff --git a/lib/loopback.js b/lib/loopback.js index 4f9859ca..1d6b1e61 100644 --- a/lib/loopback.js +++ b/lib/loopback.js @@ -6,7 +6,6 @@ var express = require('express') , fs = require('fs') , ejs = require('ejs') , path = require('path') - , proto = require('./application') , DataSource = require('loopback-datasource-juggler').DataSource , merge = require('util')._extend , assert = require('assert'); @@ -66,6 +65,9 @@ loopback.compat = require('./compat'); function createApplication() { var app = express(); + // Defer loading of `./application` until all `loopback` static methods + // are defined, because `./application` depends on loopback. + var proto = require('./application'); merge(app, proto); // Create a new instance of models registry per each app instance @@ -193,6 +195,91 @@ loopback.createModel = function (name, properties, options) { return model; }; +/** + * Create a model as described by the configuration object. + * + * @example + * + * ```js + * loopback.createModelFromConfig({ + * name: 'Author', + * properties: { + * firstName: 'string', + * lastName: 'string + * }, + * relations: { + * books: { + * model: 'Book', + * type: 'hasAndBelongsToMany' + * } + * } + * }); + * ``` + * + * @options {Object} model configuration + * @property {String} name Unique name. + * @property {Object=} properties Model properties + * @property {Object=} options Model options. Options can be specified on the + * top level config object too. E.g. `{ base: 'User' }` is the same as + * `{ options: { base: 'User' } }`. + */ +loopback.createModelFromConfig = function(config) { + var name = config.name; + var properties = config.properties; + var options = buildModelOptionsFromConfig(config); + + assert(typeof name === 'string', + 'The model-config property `name` must be a string'); + + return loopback.createModel(name, properties, options); +}; + +function buildModelOptionsFromConfig(config) { + var options = merge({}, config.options); + for (var key in config) { + if (['name', 'properties', 'options'].indexOf(key) !== -1) { + // Skip items which have special meaning + continue; + } + + if (options[key] !== undefined) { + // When both `config.key` and `config.options.key` are set, + // use the latter one + continue; + } + + options[key] = config[key]; + } + return options; +} + +/** + * Alter an existing Model class. + * @param {Model} ModelCtor The model constructor to alter. + * @options {Object} Additional configuration to apply + * @property {DataSource} dataSource Attach the model to a dataSource. + * @property {Object} relations Model relations to add/update. + */ +loopback.configureModel = function(ModelCtor, config) { + var settings = ModelCtor.settings; + + if (config.relations) { + var relations = settings.relations = settings.relations || {}; + Object.keys(config.relations).forEach(function(key) { + relations[key] = merge(relations[key] || {}, config.relations[key]); + }); + } + + // It's important to attach the datasource after we have updated + // configuration, so that the datasource picks up updated relations + if (config.dataSource) { + assert(config.dataSource instanceof DataSource, + 'Cannot configure ' + ModelCtor.modelName + + ': config.dataSource must be an instance of loopback.DataSource'); + ModelCtor.attachTo(config.dataSource); + } +}; + /** * Add a remote method to a model. * @param {Function} fn diff --git a/test/app.test.js b/test/app.test.js index 2ab4e287..dec96af6 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -122,6 +122,20 @@ describe('app', function() { }); }); + describe('app.model(ModelCtor, config)', function() { + it('attaches the model to a datasource', function() { + app.dataSource('db', { connector: 'memory' }); + var TestModel = loopback.Model.extend('TestModel'); + // TestModel was most likely already defined in a different test, + // thus TestModel.dataSource may be already set + delete TestModel.dataSource; + + app.model(TestModel, { dataSource: 'db' }); + + expect(app.models.TestModel.dataSource).to.equal(app.dataSources.db); + }); + }); + describe('app.models', function() { it('is unique per app instance', function() { app.dataSource('db', { connector: 'memory' }); diff --git a/test/loopback.test.js b/test/loopback.test.js index 7988fdb4..5cb28098 100644 --- a/test/loopback.test.js +++ b/test/loopback.test.js @@ -1,4 +1,11 @@ describe('loopback', function() { + var nameCounter = 0; + var uniqueModelName; + + beforeEach(function() { + uniqueModelName = 'TestModel-' + (++nameCounter); + }); + describe('exports', function() { it('ValidationError', function() { expect(loopback.ValidationError).to.be.a('function') @@ -119,4 +126,95 @@ describe('loopback', function() { }); }); }); + + describe('loopback.createModelFromConfig(config)', function() { + it('creates the model', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName + }); + + expect(model.prototype).to.be.instanceof(loopback.Model); + }); + + it('interprets extra first-level keys as options', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName, + base: 'User' + }); + + expect(model.prototype).to.be.instanceof(loopback.User); + }); + + it('prefers config.options.key over config.key', function() { + var model = loopback.createModelFromConfig({ + name: uniqueModelName, + base: 'User', + options: { + base: 'Application' + } + }); + + expect(model.prototype).to.be.instanceof(loopback.Application); + }); + }); + + describe('loopback.configureModel(ModelCtor, config)', function() { + it('adds new relations', function() { + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + expect(model.settings.relations).to.have.property('owner'); + }); + + it('updates existing relations', function() { + var model = loopback.Model.extend(uniqueModelName, {}, { + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + loopback.configureModel(model, { + relations: { + owner: { + model: 'Application' + } + } + }); + + expect(model.settings.relations.owner).to.eql({ + type: 'belongsTo', + model: 'Application' + }); + }); + + it('updates relations before attaching to a dataSource', function() { + var db = loopback.createDataSource({ connector: loopback.Memory }); + var model = loopback.Model.extend(uniqueModelName); + + loopback.configureModel(model, { + dataSource: db, + relations: { + owner: { + type: 'belongsTo', + model: 'User' + } + } + }); + + var owner = model.prototype.owner; + expect(owner, 'model.prototype.owner').to.be.a('function'); + expect(owner._targetClass).to.equal('User'); + }); + }); });