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 --*/
var loopback = require('loopback');
var boot = require('loopback-boot');
require('./models');
var app = module.exports = loopback();
boot(app);

View File

@ -2,49 +2,99 @@
### Model Definitions
The following is example JSON for two `Model` definitions:
"dealership" and "location".
The following two examples demonstrate how to define models.
*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
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": {
// 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
}
}
}
}
```
@ -72,8 +122,9 @@ The following is example JSON for two `Model` definitions:
var app = require('../app');
var Car = app.models.Car;
Car.prototype.honk = function() {
// make some noise
Car.prototype.honk = function(duration, cb) {
// 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
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.
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*
```js
var loopback = require('loopback');
var Car = module.exports = loopback.createModel(require('./car.json'));
Car.prototype.honk = function() {
// make some noise
module.exports = function(Car, Base) {
Car.prototype.honk = function(duration, cb) {
// make some noise for `duration` seconds
cb();
};
};
```
3. Add a new file `models/index.js` to build all models:
*models/index.js*
```js
exports.Car = require('./car');
```
4. Modify the main application file to load model definitions before booting
the application.
4. Modify the boot configuration to list the directory containing
model definitions.
```js
var loopback = require('loopback');
var boot = require('loopback-boot');
require('./models');
var app = loopback();
boot(app, __dirname);
boot(app, {
appRootDir: __dirname,
modelSources: ['./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()`.
*
* **NOTE:** The version 2.0 of loopback-boot changed the way how models
* are created. loopback-boot no longer creates the models for you,
* the `models.json` file contains only configuration options like
* dataSource and extra relations.
* are created. The `models.json` file contains only configuration options like
* dataSource and extra relations. To define a model, create a per-model
* JSON file in `models/` directory.
*
* **NOTE:** mixing `app.boot()` and `app.model(name, config)` in multiple
* 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`
* or `development`. Common values are `development`, `staging` and
* `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
*
* @header bootLoopBackApp(app, [options])

View File

@ -9,32 +9,53 @@ var commondir = require('commondir');
*/
module.exports = function addInstructionsToBrowserify(instructions, bundler) {
bundleScripts(instructions.files, bundler);
bundleModelScripts(instructions, bundler);
bundleOtherScripts(instructions, bundler);
bundleInstructions(instructions, bundler);
};
function bundleScripts(files, bundler) {
for (var key in files) {
var list = files[key];
if (!list.length) continue;
function bundleOtherScripts(instructions, bundler) {
for (var key in instructions.files) {
addScriptsToBundle(key, instructions.files[key], bundler);
}
}
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 filepath = list[ix];
var modelToFileMapping = instructions.models
.map(function(m) { return files.indexOf(m.sourceFile); });
// 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#' + key + '#' + path.relative(root, filepath);
addScriptsToBundle('models', files, bundler);
// Add the file to the bundle.
bundler.require(filepath, { expose: fileid });
// Update `sourceFile` properties with the new paths
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
// used to load the file via `require(fileid)`.
list[ix] = fileid;
}
function addScriptsToBundle(name, list, bundler) {
if (!list.length) return;
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
var bootScripts = findScripts(path.join(appRootDir, 'boot'));
var modelSources = options.modelSources || ['./models'];
var modelInstructions = buildAllModelInstructions(
appRootDir, modelsConfig, modelSources);
return {
app: appConfig,
dataSources: dataSourcesConfig,
models: modelsConfig,
models: modelInstructions,
files: {
boot: bootScripts
}
@ -138,3 +142,111 @@ function tryReadDir() {
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) {
forEachKeyedObject(instructions.models, function(key, obj) {
var model = loopback.getModel(key);
if (!model) {
throw new Error('Cannot configure unknown model ' + key);
instructions.models.forEach(function(data) {
var name = data.name;
var model;
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
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();
});
@ -53,12 +56,17 @@ function executeBundledApp(bundlePath) {
}
function createBrowserLikeContext() {
return vm.createContext({
var context = {
// required by browserify
XMLHttpRequest: function() { throw new Error('not implemented'); },
// used by loopback to detect browser runtime
window: {},
localStorage: {
// used by `debug` module
debug: process.env.DEBUG
},
// used by `debug` module
document: { documentElement: { style: {} } },
// allow the browserified code to log messages
// call `printContextLogs(context)` to print the accumulated messages
@ -78,7 +86,12 @@ function createBrowserLikeContext() {
error: []
},
}
});
};
// `window` is used by loopback to detect browser runtime
context.window = context;
return vm.createContext(context);
}
function printContextLogs(context) {

View File

@ -1,7 +1,6 @@
var boot = require('../');
var fs = require('fs-extra');
var path = require('path');
var assert = require('assert');
var expect = require('must');
var sandbox = require('./helpers/sandbox');
var appdir = require('./helpers/appdir');
@ -59,7 +58,15 @@ describe('compiler', 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() {
@ -70,8 +77,16 @@ describe('compiler', function() {
describe('from directory', function() {
it('loads config files', function() {
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() {
@ -191,7 +206,8 @@ describe('compiler', function() {
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() {
@ -229,5 +245,123 @@ describe('compiler', function() {
.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');
// ensure simple-app's models are known by loopback
require(path.join(SIMPLE_APP, '/models'));
var app;
describe('executor', function() {
beforeEach(sandbox.reset);
@ -31,11 +27,14 @@ describe('executor', function() {
foo: { bar: 'bat' },
baz: true
},
models: {
'User': {
dataSource: 'the-db'
models: [
{
name: 'User',
config: {
dataSource: 'the-db'
}
}
},
],
dataSources: {
'the-db': {
connector: 'memory',
@ -44,7 +43,7 @@ describe('executor', function() {
}
});
it('instantiates models', function() {
it('configures models', function() {
boot.execute(app, dummyInstructions);
assert(app.models);
assert(app.models.User);
@ -55,6 +54,60 @@ describe('executor', function() {
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() {
boot.execute(app, dummyInstructions);
assert.equal(app.models.User.dataSource, app.dataSources.theDb);
@ -203,7 +256,7 @@ assert.isFunc = function (obj, name) {
function someInstructions(values) {
var result = {
app: values.app || {},
models: values.models || {},
models: values.models || [],
dataSources: values.dataSources || {},
files: {
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"
}