Add createModelFromConfig and configureModel()

Add new API allowing developers to split the model definition and
configuration into two steps:

 1. Build models from JSON config, export them for re-use:

  ```js
  var Customer = loopback.createModelFromConfig({
    name: 'Customer',
    base: 'User',
    properties: {
      address: 'string'
    }
  });
  ```

 2. Attach existing models to a dataSource and a loopback app,
    modify certain model aspects like relations:

  ```js
  loopback.configureModel(Customer, {
    dataSource: db,
    relations: { /* ... */ }
  });
  ```

Rework `app.model` to use `loopback.configureModel` under the hood.
Here is the new usage:

```js
var Customer = require('./models').Customer;

app.model(Customer, {
  dataSource: 'db',
  relations: { /* ... */ }
});
```

In order to preserve backwards compatibility with loopback 1.x,
`app.model(name, config)` calls both `createModelFromConfig`
and `configureModel`.
This commit is contained in:
Miroslav Bajtoš 2014-06-05 17:41:12 +02:00
parent 93a74f2821
commit f844459311
4 changed files with 268 additions and 53 deletions

View File

@ -3,7 +3,7 @@
*/ */
var DataSource = require('loopback-datasource-juggler').DataSource var DataSource = require('loopback-datasource-juggler').DataSource
, ModelBuilder = require('loopback-datasource-juggler').ModelBuilder , loopback = require('../')
, compat = require('./compat') , compat = require('./compat')
, assert = require('assert') , assert = require('assert')
, fs = require('fs') , 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. * `app.models` object.
* *
* ```js * ```js
* var Widget = app.model('Widget', {dataSource: 'db'}); * // Attach an existing model
* Widget.create({name: 'pencil'}); * var User = loopback.User;
* app.models.Widget.find(function(err, widgets) { * app.model(User);
* console.log(widgets[0]); // => {name: 'pencil'} *
* // 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. * @options {Object} config The model's configuration.
* @property {String|DataSource} dataSource The `DataSource` to which to attach the model. * @property {String|DataSource} dataSource The `DataSource` to which to
* @property {Object} [options] an object containing `Model` options. * attach the model.
* @property {ACL[]} [options.acls] an array of `ACL` definitions. * @property {Boolean} [public] whether the model should be exposed via REST API
* @property {String[]} [options.hidden] **experimental** an array of properties to hide when accessed remotely. * @property {Object} [relations] relations to add/update
* @property {Object} [properties] object defining the `Model` properties in [LoopBack Definition Language](http://docs.strongloop.com/loopback-datasource-juggler/#loopback-definition-language).
* @end * @end
* @returns {ModelConstructor} the model class * @returns {ModelConstructor} the model class
*/ */
app.model = function (Model, config) { app.model = function (Model, config) {
if(arguments.length === 1) { if(arguments.length === 1) {
assert(typeof Model === 'function', 'app.model(Model) => Model must be a function / constructor'); assertIsModel(Model);
assert(Model.modelName, 'Model must have a "modelName" property');
var remotingClassName = compat.getClassNameForRemoting(Model);
if(Model.sharedClass) { if(Model.sharedClass) {
this.remotes().addClass(Model.sharedClass); this.remotes().addClass(Model.sharedClass);
} }
@ -117,22 +124,53 @@ app.model = function (Model, config) {
Model.shared = true; Model.shared = true;
Model.app = this; Model.app = this;
Model.emit('attached', 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[modelName] =
this.models[classify(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); this.model(Model);
} }
return 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); return require('./loopback').createDataSource(config);
} }
function modelFromConfig(name, config, app) { function configureModel(ModelCtor, config, app) {
var options = buildModelOptionsFromConfig(config); assertIsModel(ModelCtor);
var properties = config.properties;
var ModelCtor = require('./loopback').createModel(name, properties, options);
var dataSource = config.dataSource; var dataSource = config.dataSource;
if(typeof dataSource === 'string') { if(typeof dataSource === 'string') {
dataSource = app.dataSources[dataSource]; 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); config = extend({}, config);
return ModelCtor; config.dataSource = dataSource;
}
function buildModelOptionsFromConfig(config) { loopback.configureModel(ModelCtor, 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;
} }
function requireDir(dir, basenames) { 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) { function isDataSource(obj) {
return obj instanceof DataSource; return obj instanceof DataSource;
} }

View File

@ -6,7 +6,6 @@ var express = require('express')
, fs = require('fs') , fs = require('fs')
, ejs = require('ejs') , ejs = require('ejs')
, path = require('path') , path = require('path')
, proto = require('./application')
, DataSource = require('loopback-datasource-juggler').DataSource , DataSource = require('loopback-datasource-juggler').DataSource
, merge = require('util')._extend , merge = require('util')._extend
, assert = require('assert'); , assert = require('assert');
@ -66,6 +65,9 @@ loopback.compat = require('./compat');
function createApplication() { function createApplication() {
var app = express(); 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); merge(app, proto);
// Create a new instance of models registry per each app instance // Create a new instance of models registry per each app instance
@ -193,6 +195,91 @@ loopback.createModel = function (name, properties, options) {
return model; 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. * Add a remote method to a model.
* @param {Function} fn * @param {Function} fn

View File

@ -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() { describe('app.models', function() {
it('is unique per app instance', function() { it('is unique per app instance', function() {
app.dataSource('db', { connector: 'memory' }); app.dataSource('db', { connector: 'memory' });

View File

@ -1,4 +1,11 @@
describe('loopback', function() { describe('loopback', function() {
var nameCounter = 0;
var uniqueModelName;
beforeEach(function() {
uniqueModelName = 'TestModel-' + (++nameCounter);
});
describe('exports', function() { describe('exports', function() {
it('ValidationError', function() { it('ValidationError', function() {
expect(loopback.ValidationError).to.be.a('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');
});
});
}); });