Merge pull request #10 from strongloop/auto-require-model-definitions

[2.0] Rework model configuration
This commit is contained in:
Miroslav Bajtoš 2014-06-14 09:37:21 +02:00
commit e22ecd39ce
13 changed files with 527 additions and 103 deletions

View File

@ -46,7 +46,6 @@ contained in the browser bundle:
/*-- app.js --*/ /*-- app.js --*/
var loopback = require('loopback'); var loopback = require('loopback');
var boot = require('loopback-boot'); var boot = require('loopback-boot');
require('./models');
var app = module.exports = loopback(); var app = module.exports = loopback();
boot(app); boot(app);

View File

@ -2,49 +2,99 @@
### Model Definitions ### Model Definitions
The following is example JSON for two `Model` definitions: The following two examples demonstrate how to define models.
"dealership" and "location".
*models/dealership.json*
```json
{
"name": "dealership",
"relations": {
"cars": {
"type": "hasMany",
"model": "Car",
"foreignKey": "dealerId"
}
},
"properties": {
"id": {"id": true},
"name": "String",
"zip": "Number",
"address": "String"
}
}
```
*models/car.json*
```json
{
"name": "car",
"properties": {
"id": {
"type": "String",
"required": true,
"id": true
},
"make": {
"type": "String",
"required": true
},
"model": {
"type": "String",
"required": true
}
}
}
```
To add custom methods to your models, create a `.js` file with the same name
as the `.json` file:
*models/car.js*
```js ```js
module.exports = function(Car, Base) {
// Car is the model constructor
// Base is the parent model (e.g. loopback.PersistedModel)
// Define a static method
Car.customMethod = function(cb) {
// do some work
cb();
};
Car.prototype.honk = function(duration, cb) {
// make some noise for `duration` seconds
cb();
};
Car.setup = function() {
Base.setup.call(this);
// configure validations,
// configure remoting for methods, etc.
};
}
```
### Model Configuration
The following is an example JSON configuring the models defined above
for use in an loopback application.
`dataSource` options is a reference, by name, to a data-source defined
in `datasources.json`.
*models.json*
```json
{ {
"dealership": { "dealership": {
// a reference, by name, to a dataSource definition
"dataSource": "my-db", "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": { "car": {
"dataSource": "my-db" "dataSource": "my-db"
"properties": {
"id": {
"type": "String",
"required": true,
"id": true
},
"make": {
"type": "String",
"required": true
},
"model": {
"type": "String",
"required": true
}
}
} }
} }
``` ```
@ -72,8 +122,9 @@ The following is example JSON for two `Model` definitions:
var app = require('../app'); var app = require('../app');
var Car = app.models.Car; var Car = app.models.Car;
Car.prototype.honk = function() { Car.prototype.honk = function(duration, cb) {
// make some noise // make some noise for `duration` seconds
cb();
}; };
``` ```
@ -92,7 +143,7 @@ developer to create them before booting the app.**
The folder `models/` has a different semantincs in 2.x than in 1.x. Instead The folder `models/` has a different semantincs in 2.x than in 1.x. Instead
of extending Models already defined by `app.boot` and `models.json`, of extending Models already defined by `app.boot` and `models.json`,
it is an encapsulated component that defines all Models independently of it provides a set of Model definitions that do not depend on
any application that may use them. any application that may use them.
Perform the following steps to update a 1.x project for loopback-boot 2.x. Perform the following steps to update a 1.x project for loopback-boot 2.x.
@ -122,37 +173,33 @@ All code samples are referring to the sample project described above.
} }
``` ```
2. Change per-model javascript files to build and export the Model class: 2. Change per-model javascript files to export a function that adds
custom methods to the model class.
*models/car.js* *models/car.js*
```js ```js
var loopback = require('loopback'); module.exports = function(Car, Base) {
var Car = module.exports = loopback.createModel(require('./car.json')); Car.prototype.honk = function(duration, cb) {
// make some noise for `duration` seconds
Car.prototype.honk = function() { cb();
// make some noise };
}; };
``` ```
3. Add a new file `models/index.js` to build all models: 4. Modify the boot configuration to list the directory containing
model definitions.
*models/index.js*
```js
exports.Car = require('./car');
```
4. Modify the main application file to load model definitions before booting
the application.
```js ```js
var loopback = require('loopback'); var loopback = require('loopback');
var boot = require('loopback-boot'); var boot = require('loopback-boot');
require('./models');
var app = loopback(); var app = loopback();
boot(app, __dirname); boot(app, {
appRootDir: __dirname,
modelSources: ['./models']
});
``` ```
#### Attaching built-in models #### Attaching built-in models

View File

@ -33,9 +33,9 @@ var addInstructionsToBrowserify = require('./lib/bundler');
* `/boot` subdirectory of the application root directory with `require()`. * `/boot` subdirectory of the application root directory with `require()`.
* *
* **NOTE:** The version 2.0 of loopback-boot changed the way how models * **NOTE:** The version 2.0 of loopback-boot changed the way how models
* are created. loopback-boot no longer creates the models for you, * are created. The `models.json` file contains only configuration options like
* the `models.json` file contains only configuration options like * dataSource and extra relations. To define a model, create a per-model
* dataSource and extra relations. * JSON file in `models/` directory.
* *
* **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple * **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple
* files may result in models being **undefined** due to race conditions. * files may result in models being **undefined** due to race conditions.
@ -60,6 +60,8 @@ var addInstructionsToBrowserify = require('./lib/bundler');
* @property {String} [env] Environment type, defaults to `process.env.NODE_ENV` * @property {String} [env] Environment type, defaults to `process.env.NODE_ENV`
* or `development`. Common values are `development`, `staging` and * or `development`. Common values are `development`, `staging` and
* `production`; however the applications are free to use any names. * `production`; however the applications are free to use any names.
* @property {Array.<String>} [modelSources] List of directories where to look
* for files containing model definitions.
* @end * @end
* *
* @header bootLoopBackApp(app, [options]) * @header bootLoopBackApp(app, [options])

View File

@ -9,32 +9,53 @@ var commondir = require('commondir');
*/ */
module.exports = function addInstructionsToBrowserify(instructions, bundler) { module.exports = function addInstructionsToBrowserify(instructions, bundler) {
bundleScripts(instructions.files, bundler); bundleModelScripts(instructions, bundler);
bundleOtherScripts(instructions, bundler);
bundleInstructions(instructions, bundler); bundleInstructions(instructions, bundler);
}; };
function bundleScripts(files, bundler) { function bundleOtherScripts(instructions, bundler) {
for (var key in files) { for (var key in instructions.files) {
var list = files[key]; addScriptsToBundle(key, instructions.files[key], bundler);
if (!list.length) continue; }
}
var root = commondir(files[key].map(path.dirname)); function bundleModelScripts(instructions, bundler) {
var files = instructions.models
.map(function(m) { return m.sourceFile; })
.filter(function(f) { return !!f; });
for (var ix in list) { var modelToFileMapping = instructions.models
var filepath = list[ix]; .map(function(m) { return files.indexOf(m.sourceFile); });
// Build a short unique id that does not expose too much addScriptsToBundle('models', files, bundler);
// information about the file system, but still preserves
// useful information about where is the file coming from.
var fileid = 'loopback-boot#' + key + '#' + path.relative(root, filepath);
// Add the file to the bundle. // Update `sourceFile` properties with the new paths
bundler.require(filepath, { expose: fileid }); modelToFileMapping.forEach(function(fileIx, modelIx) {
if (fileIx === -1) return;
instructions.models[modelIx].sourceFile = files[fileIx];
});
}
// Rewrite the instructions entry with the new id that will be function addScriptsToBundle(name, list, bundler) {
// used to load the file via `require(fileid)`. if (!list.length) return;
list[ix] = fileid;
} var root = commondir(list.map(path.dirname));
for (var ix in list) {
var filepath = list[ix];
// Build a short unique id that does not expose too much
// information about the file system, but still preserves
// useful information about where is the file coming from.
var fileid = 'loopback-boot#' + name + '#' + path.relative(root, filepath);
// Add the file to the bundle.
bundler.require(filepath, { expose: fileid });
// Rewrite the instructions entry with the new id that will be
// used to load the file via `require(fileid)`.
list[ix] = fileid;
} }
} }

View File

@ -42,10 +42,14 @@ module.exports = function compile(options) {
// require directories // require directories
var bootScripts = findScripts(path.join(appRootDir, 'boot')); var bootScripts = findScripts(path.join(appRootDir, 'boot'));
var modelSources = options.modelSources || ['./models'];
var modelInstructions = buildAllModelInstructions(
appRootDir, modelsConfig, modelSources);
return { return {
app: appConfig, app: appConfig,
dataSources: dataSourcesConfig, dataSources: dataSourcesConfig,
models: modelsConfig, models: modelInstructions,
files: { files: {
boot: bootScripts boot: bootScripts
} }
@ -138,3 +142,111 @@ function tryReadDir() {
return []; return [];
} }
} }
function buildAllModelInstructions(rootDir, modelsConfig, sources) {
var registry = findModelDefinitions(rootDir, sources);
var modelNamesToBuild = addAllBaseModels(registry, Object.keys(modelsConfig));
var instructions = modelNamesToBuild
.map(function createModelInstructions(name) {
var config = modelsConfig[name];
var definition = registry[name] || {};
debug('Using model "%s"\nConfiguration: %j\nDefinition %j',
name, config, definition.definition);
return {
name: name,
config: config,
definition: definition.definition,
sourceFile: definition.sourceFile
};
});
return sortByInheritance(instructions);
}
function addAllBaseModels(registry, modelNames) {
var result = [];
var visited = {};
while (modelNames.length) {
var name = modelNames.shift();
result.push(name);
var definition = registry[name] && registry[name].definition;
if (!definition) continue;
var base = definition.base || definition.options && definition.options.base;
if (!base || base in visited) continue;
visited[base] = true;
// ignore built-in models like User
if (!registry[base]) continue;
modelNames.push(base);
}
return result;
}
function sortByInheritance(instructions) {
// TODO implement topological sort
return instructions.reverse();
}
function findModelDefinitions(rootDir, sources) {
var registry = {};
sources.forEach(function(src) {
var srcDir = path.resolve(rootDir, src);
var files = tryReadDir(srcDir);
files
.filter(function(f) {
return f[0] !== '_' && path.extname(f) === '.json';
})
.forEach(function(f) {
var fullPath = path.resolve(srcDir, f);
var entry = loadModelDefinition(rootDir, fullPath);
var modelName = entry.definition.name;
if (!modelName) {
debug('Skipping model definition without Model name: %s',
path.relative(srcDir, fullPath));
return;
}
registry[modelName] = entry;
});
});
return registry;
}
function loadModelDefinition(rootDir, jsonFile) {
var definition = require(jsonFile);
var sourceFile = path.join(
path.dirname(jsonFile),
path.basename(jsonFile, path.extname(jsonFile)));
try {
// resolve the file to `.js` or any other supported extension like `.coffee`
sourceFile = require.resolve(sourceFile);
} catch (err) {
debug('Model source code not found: %s - %s', sourceFile, err.code || err);
sourceFile = undefined;
}
if (sourceFile === jsonFile)
sourceFile = undefined;
debug('Found model "%s" - %s %s', definition.name,
path.relative(rootDir, jsonFile),
sourceFile ? path.relative(rootDir, sourceFile) : '(no source file)');
return {
definition: definition,
sourceFile: sourceFile
};
}

View File

@ -95,12 +95,37 @@ function setupDataSources(app, instructions) {
} }
function setupModels(app, instructions) { function setupModels(app, instructions) {
forEachKeyedObject(instructions.models, function(key, obj) { instructions.models.forEach(function(data) {
var model = loopback.getModel(key); var name = data.name;
if (!model) { var model;
throw new Error('Cannot configure unknown model ' + key);
if (!data.definition) {
model = loopback.getModel(name);
if (!model) {
throw new Error('Cannot configure unknown model ' + name);
}
debug('Configuring existing model %s', name);
} else {
debug('Creating new model %s %j', name, data.definition);
model = loopback.createModel(data.definition);
if (data.sourceFile) {
debug('Loading customization script %s', data.sourceFile);
var code = require(data.sourceFile);
if (typeof code === 'function') {
debug('Customizing model %s', name);
// NOTE model.super_ is set by Node's util.inherits
code(model, model.super_);
} else {
debug('Skipping model file %s - `module.exports` is not a function',
data.sourceFile);
}
}
} }
app.model(model, obj);
// Skip base models that are not exported to the app
if (!data.config) return;
app.model(model, data.config);
}); });
} }

View File

@ -17,6 +17,9 @@ describe('browser support', function() {
// configured in fixtures/browser-app/boot/configure.js // configured in fixtures/browser-app/boot/configure.js
expect(app.settings).to.have.property('custom-key', 'custom-value'); expect(app.settings).to.have.property('custom-key', 'custom-value');
expect(Object.keys(app.models)).to.include('Customer');
expect(app.models.Customer.settings)
.to.have.property('_customized', 'Customer');
done(); done();
}); });
@ -53,12 +56,17 @@ function executeBundledApp(bundlePath) {
} }
function createBrowserLikeContext() { function createBrowserLikeContext() {
return vm.createContext({ var context = {
// required by browserify // required by browserify
XMLHttpRequest: function() { throw new Error('not implemented'); }, XMLHttpRequest: function() { throw new Error('not implemented'); },
// used by loopback to detect browser runtime localStorage: {
window: {}, // used by `debug` module
debug: process.env.DEBUG
},
// used by `debug` module
document: { documentElement: { style: {} } },
// allow the browserified code to log messages // allow the browserified code to log messages
// call `printContextLogs(context)` to print the accumulated messages // call `printContextLogs(context)` to print the accumulated messages
@ -78,7 +86,12 @@ function createBrowserLikeContext() {
error: [] error: []
}, },
} }
}); };
// `window` is used by loopback to detect browser runtime
context.window = context;
return vm.createContext(context);
} }
function printContextLogs(context) { function printContextLogs(context) {

View File

@ -1,7 +1,6 @@
var boot = require('../'); var boot = require('../');
var fs = require('fs-extra'); var fs = require('fs-extra');
var path = require('path'); var path = require('path');
var assert = require('assert');
var expect = require('must'); var expect = require('must');
var sandbox = require('./helpers/sandbox'); var sandbox = require('./helpers/sandbox');
var appdir = require('./helpers/appdir'); var appdir = require('./helpers/appdir');
@ -59,7 +58,15 @@ describe('compiler', function() {
}); });
it('has models definition', function() { it('has models definition', function() {
expect(instructions.models).to.eql(options.models); expect(instructions.models).to.have.length(1);
expect(instructions.models[0]).to.eql({
name: 'foo-bar-bat-baz',
config: {
dataSource: 'the-db'
},
definition: undefined,
sourceFile: undefined
});
}); });
it('has datasources definition', function() { it('has datasources definition', function() {
@ -70,8 +77,16 @@ describe('compiler', function() {
describe('from directory', function() { describe('from directory', function() {
it('loads config files', function() { it('loads config files', function() {
var instructions = boot.compile(SIMPLE_APP); var instructions = boot.compile(SIMPLE_APP);
assert(instructions.models.User);
assert(instructions.models.User.dataSource); expect(instructions.models).to.have.length(1);
expect(instructions.models[0]).to.eql({
name: 'User',
config: {
dataSource: 'db'
},
definition: undefined,
sourceFile: undefined
});
}); });
it('merges datasource configs from multiple files', function() { it('merges datasource configs from multiple files', function() {
@ -191,7 +206,8 @@ describe('compiler', function() {
modelsRootDir: path.resolve(appdir.PATH, 'custom') modelsRootDir: path.resolve(appdir.PATH, 'custom')
}); });
expect(instructions.models).to.have.property('foo'); expect(instructions.models).to.have.length(1);
expect(instructions.models[0]).to.have.property('name', 'foo');
}); });
it('includes boot/*.js scripts', function() { it('includes boot/*.js scripts', function() {
@ -229,5 +245,123 @@ describe('compiler', function() {
.to.throw(/unsupported 1\.x format/); .to.throw(/unsupported 1\.x format/);
}); });
it('loads models from `./models`', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('models/car.json', { name: 'Car' });
appdir.writeFileSync('models/car.js', '');
var instructions = boot.compile(appdir.PATH);
expect(instructions.models).to.have.length(1);
expect(instructions.models[0]).to.eql({
name: 'Car',
config: {
dataSource: 'db'
},
definition: {
name: 'Car'
},
sourceFile: path.resolve(appdir.PATH, 'models', 'car.js')
});
});
it('supports `modelSources` option', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('custom-models/car.json', { name: 'Car' });
appdir.writeFileSync('custom-models/car.js', '');
var instructions = boot.compile({
appRootDir: appdir.PATH,
modelSources: ['./custom-models']
});
expect(instructions.models).to.have.length(1);
expect(instructions.models[0]).to.eql({
name: 'Car',
config: {
dataSource: 'db'
},
definition: {
name: 'Car'
},
sourceFile: path.resolve(appdir.PATH, 'custom-models', 'car.js')
});
});
it('handles model definitions with no code', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('models/car.json', { name: 'Car' });
var instructions = boot.compile(appdir.PATH);
expect(instructions.models).to.eql([{
name: 'Car',
config: {
dataSource: 'db'
},
definition: {
name: 'Car'
},
sourceFile: undefined
}]);
});
it('excludes models not listed in `models.json`', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('models/car.json', { name: 'Car' });
appdir.writeConfigFileSync('models/bar.json', { name: 'Bar' });
var instructions = boot.compile(appdir.PATH);
var models = instructions.models.map(getNameProperty);
expect(models).to.eql(['Car']);
});
it('includes models used as Base models', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('models/car.json', {
name: 'Car',
base: 'Vehicle'
});
appdir.writeConfigFileSync('models/vehicle.json', {
name: 'Vehicle'
});
var instructions = boot.compile(appdir.PATH);
var models = instructions.models;
var modelNames = models.map(getNameProperty);
expect(modelNames).to.eql(['Vehicle', 'Car']);
expect(models[0].config).to.equal(undefined);
});
it('excludes pre-built base models', function() {
appdir.createConfigFilesSync({}, {}, {
Car: { dataSource: 'db' }
});
appdir.writeConfigFileSync('models/car.json', {
name: 'Car',
base: 'Model'
});
var instructions = boot.compile(appdir.PATH);
var modelNames = instructions.models.map(getNameProperty);
expect(modelNames).to.eql(['Car']);
});
}); });
}); });
function getNameProperty(obj) {
return obj.name;
}

View File

@ -8,12 +8,8 @@ var appdir = require('./helpers/appdir');
var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app');
// ensure simple-app's models are known by loopback
require(path.join(SIMPLE_APP, '/models'));
var app; var app;
describe('executor', function() { describe('executor', function() {
beforeEach(sandbox.reset); beforeEach(sandbox.reset);
@ -31,11 +27,14 @@ describe('executor', function() {
foo: { bar: 'bat' }, foo: { bar: 'bat' },
baz: true baz: true
}, },
models: { models: [
'User': { {
dataSource: 'the-db' name: 'User',
config: {
dataSource: 'the-db'
}
} }
}, ],
dataSources: { dataSources: {
'the-db': { 'the-db': {
connector: 'memory', connector: 'memory',
@ -44,7 +43,7 @@ describe('executor', function() {
} }
}); });
it('instantiates models', function() { it('configures models', function() {
boot.execute(app, dummyInstructions); boot.execute(app, dummyInstructions);
assert(app.models); assert(app.models);
assert(app.models.User); assert(app.models.User);
@ -55,6 +54,60 @@ describe('executor', function() {
assert.isFunc(app.models.User, 'create'); assert.isFunc(app.models.User, 'create');
}); });
it('defines and customizes models', function() {
appdir.writeFileSync('models/Customer.js', 'module.exports = ' +
function(Customer, Base) {
Customer.settings._customized = 'Customer';
Base.settings._customized = 'Base';
}.toString());
boot.execute(app, someInstructions({
dataSources: { db: { connector: 'memory' } },
models: [
{
name: 'Customer',
config: { dataSource: 'db' },
definition: {
name: 'Customer',
base: 'User',
},
sourceFile: path.resolve(appdir.PATH, 'models', 'Customer.js')
}
]
}));
expect(app.models.Customer).to.exist();
expect(app.models.Customer.settings._customized).to.be.equal('Customer');
expect(loopback.User.settings._customized).to.equal('Base');
});
it('defines model without attaching it', function() {
boot.execute(app, someInstructions({
dataSources: { db: { connector: 'memory' } },
models: [
{
name: 'Vehicle',
config: undefined,
definition: {
name: 'Vehicle'
},
sourceFile: undefined
},
{
name: 'Car',
config: { dataSource: 'db' },
definition: {
name: 'Car',
base: 'Vehicle',
},
sourceFile: undefined
},
]
}));
expect(Object.keys(app.models)).to.eql(['Car']);
});
it('attaches models to data sources', function() { it('attaches models to data sources', function() {
boot.execute(app, dummyInstructions); boot.execute(app, dummyInstructions);
assert.equal(app.models.User.dataSource, app.dataSources.theDb); assert.equal(app.models.User.dataSource, app.dataSources.theDb);
@ -203,7 +256,7 @@ assert.isFunc = function (obj, name) {
function someInstructions(values) { function someInstructions(values) {
var result = { var result = {
app: values.app || {}, app: values.app || {},
models: values.models || {}, models: values.models || [],
dataSources: values.dataSources || {}, dataSources: values.dataSources || {},
files: { files: {
boot: [] boot: []

View File

@ -0,0 +1,5 @@
{
"db": {
"connector": "remote"
}
}

5
test/fixtures/browser-app/models.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"Customer": {
"dataSource": "db"
}
}

View File

@ -0,0 +1,4 @@
module.exports = function(Customer, Base) {
Customer.settings._customized = 'Customer';
Base.settings._customized = 'Base';
};

View File

@ -0,0 +1,4 @@
{
"name": "Customer",
"base": "User"
}