diff --git a/README.md b/README.md index 60d8a55..2b3c406 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,29 @@ app.listen(); See [API docs](http://apidocs.strongloop.com/loopback-boot/#api) for complete API reference. +## Versions + +The version range `1.x` is backwards compatible with `app.boot` provided +by LoopBack 1.x versions and the project layout scaffolded by `slc lb project` +up to slc version 2.5. + +The version range `2.x` supports the new project layout as scaffolded by +`yo loopback`. + +This document describes the configuration conventions of the `2.x` versions. +See [Migrating from 1.x to 2.x](http://apidocs.strongloop.com/loopback-boot/#migrating-from-1x-to-2x) +for step-by-step instructions on how to upgrade existing projects. + ## Configurations and conventions The bootstrapping process takes care of the following tasks: - Configuration of data-sources. - - Definition and configuration of custom Models, attaching models to - data-sources. + - Definition of custom Models + - Configuration of models, attaching models to data-sources. - Configuration of app settings like `host`, `port` or `restApiRoot`. - - Running additional boot scripts to keep the custom setup code in multiple - small files as opposed to keeping everything in the main app file. + - Running additional boot scripts, so that the custom setup code can be kept + in multiple small files as opposed to keeping everything in the main app file. Below is the typical project layout. See the following sections for description of the project files. @@ -43,7 +56,7 @@ of the project files. ``` project/ app.js - app.json + config.json datasources.json models.json models/ @@ -52,13 +65,13 @@ project/ ### App settings -The settings are loaded from the file `app.json` in the project root directory +The settings are loaded from the file `config.json` in the project root directory and can be accessed via `app.get('option-name')` from the code. -Additionally, the following files can provide values to override `app.json`: +Additionally, the following files can provide values to override `config.json`: - - `app.local.js` or `app.local.json` - - `app.{env}.js` or `app.{env}.json`, where `{env}` is the value of `NODE_ENV` + - `config.local.js` or `config.local.json` + - `config.{env}.js` or `config.{env}.json`, where `{env}` is the value of `NODE_ENV` (typically `development` or `production`) **NOTE:** The additional files can override the top-level keys with @@ -67,7 +80,7 @@ not supported at the moment. #### Example settings -*app.json* +*config.json* ```json { @@ -77,7 +90,7 @@ not supported at the moment. } ``` -*app.production.js* +*config.production.js* ```js module.exports = { @@ -132,63 +145,67 @@ not supported at the moment. } ``` -### Models +### Models: definition -App models are loaded from the file `models.json`. +Custom models are defined using JSON files in `models/` directory, +one JSON file per model. #### Example models -The following is example JSON for two `Model` definitions: +The following are example JSON files for two `Model` definitions: `Dealership` and `Location`. +*models/dealership.json* + ```js { - // the key is the model name - "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 model name + "name": "Dealership", + // 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" - // options can be specified at the top level too - "relations": { - "dealer": { - "type": "belongsTo", - "model": "Dealership", - "foreignKey": "dealerId" - }, - } - "properties": { - "id": { - "type": "String", - "required": true, - "id": true - }, - "make": { - "type": "String", - "required": true - }, - "model": { - "type": "String", - "required": true - } + // the properties passed to Model.extend(name, properties, options) + "properties": { + "id": {"id": true}, + "name": "String", + "zip": "Number", + "address": "String" + } +} +``` + +*models/car.json* +```js +{ + "name": "Car", + // options can be specified at the top level too + "relations": { + "dealer": { + "type": "belongsTo", + "model": "Dealership", + "foreignKey": "dealerId" + }, + } + "properties": { + "id": { + "type": "String", + "required": true, + "id": true + }, + "make": { + "type": "String", + "required": true + }, + "model": { + "type": "String", + "required": true } } } @@ -196,26 +213,85 @@ The following is example JSON for two `Model` definitions: #### Adding custom methods to models -The models created from `models.json` come with the set of built-in methods +The models created from JSON files come with the set of built-in methods like `find` and `create`. To implement your custom methods, you should -create a javascript file in `models/` directory named after the model -and define the methods there. +create a javascript file in `models/` directory with the same base-name +as the JSON file containing model definition (e.g. `models/car.js` for +`models/car.json`) and define the methods there. Example: *models/car.js* ```js -module.exports = function(app) { - var Car = app.models.Car; +// Car is the model constructor +// Base is the parent model (e.g. loopback.PersistedModel) +module.exports = function(Car, Base) { + // Define a static method + Car.customMethod = function(cb) { + // do some work + cb(); + }; + // Define an instance (prototype) method Car.prototype.honk = function(duration, cb) { // make some noise for `duration` seconds cb(); }; + + // Provide a custom setup method + Car.setup = function() { + Base.setup.call(this); + + // configure validations, + // configure remoting for methods, etc. + }; }; ``` +### Models: configuration + +Before the models can be used in a loopback application, they have to be +configured - attached to a data-source, exposed via the REST API, and so on. + +The configuration is described in the file `models.json`: + +```js +{ + // the key is the model name + "Dealership": { + // a reference, by name, to a dataSource definition + "dataSource": "my-db" + }, + "Car": { + "dataSource": "my-db", + // do not expose Car over the REST API + "public": false + } +} +``` + +The bootstrapper will automatically load definition of every custom model +configured in `models.json`. By default, the definition files are loaded from +`models/` subdirectory. However, it is possible to specify a different location +(or even multiple locations) via `_meta.sources`: + +```js +{ + "_meta": { + "sources": [ + // all paths are relative to models.json + "./models" + "./node_modules/foobar/models" + ] + }, + // use the `FooBar` model from the `foobar` module + "FooBar": { + "dataSource": "db" + } +} +``` + ### Boot scripts When the data sources and models are configured, the bootstrapper invokes diff --git a/docs.json b/docs.json index c79ebcc..5607f8f 100644 --- a/docs.json +++ b/docs.json @@ -6,6 +6,7 @@ "depth": 2 }, "index.js", - "browser.js" + "browser.js", + "docs/migrating-from-1x-to-2x.md" ] } diff --git a/docs/migrating-from-1x-to-2x.md b/docs/migrating-from-1x-to-2x.md new file mode 100644 index 0000000..0f3177e --- /dev/null +++ b/docs/migrating-from-1x-to-2x.md @@ -0,0 +1,133 @@ +## Migrating from 1.x to 2.x + +**Starting point: a sample 1.x project** + +*models.json* + +```json +{ + "car": { + "properties": { + "color": "string", + }, + "dataSource": "db" + } +} +``` + +*models/car.js* + +```js +var app = require('../app'); +var Car = app.models.Car; + +Car.prototype.honk = function(duration, cb) { + // make some noise for `duration` seconds + cb(); +}; +``` + +*app.js* +```js +var loopback = require('loopback'); +var boot = require('loopback-boot'); +var app = loopback(); +boot(app, __dirname); +``` + +### App settings + +The files with applications settings were renamed from `app.*` to `config.*`. +Rename the following files to upgrade a 1.x project for loopback-boot 2.x: + + - `app.json` to `config.json` + - `app.local.json` to `config.local.json` + - `app.local.js` to `config.local.js` + - etc. + +### Data sources + +The configuration of data sources remains the same in both 1.x and 2.x +versions. + +### Models + +**The 2.x version of loopback-boot no longer creates Models, it's up to the +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 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. +All code samples are referring to the sample project described above. + + 1. Move all Model-definition metadata from `models.json` + to new per-model json files in `models/` directory. + + *models/car.json* + + ```json + { + "name": "car", + "properties": { + "color": "string", + } + } + ``` + + *models.json* + + ```json + { + "car": { + "dataSource": "db" + } + } + ``` + + 2. Change per-model javascript files to export a function that adds + custom methods to the model class. + + *models/car.js* + + ```js + module.exports = function(Car, Base) { + Car.prototype.honk = function(duration, cb) { + // make some noise for `duration` seconds + cb(); + }; + }; + ``` + + 3. If your model definitions are not in `./models`, then add an entry + to `models.json` to specify the paths where to look for model definitions. + + *models.json* + + ```json + { + "_meta": { + "sources": ["./custom/path/to/models"] + }, + "Car": { + "dataSource": "db" + } + } + ``` + +### Attaching built-in models + +Models provided by LoopBack, such as `User` or `Role`, are no longer +automatically attached to default data-sources. The data-source configuration +entry `defaultForType` is silently ignored. + +You have to explicitly configure all built-in models used by your application +in the `models.json` file. + +``` +{ + "Role": { "dataSource": "db" } +} +``` diff --git a/index.js b/index.js index e95efa4..84db3d9 100644 --- a/index.js +++ b/index.js @@ -20,10 +20,10 @@ var addInstructionsToBrowserify = require('./lib/bundler'); * 1. Creates DataSources from the `datasources.json` file in the application * root directory. * - * 2. Creates Models from the `models.json` file in the application + * 2. Configures Models from the `models.json` file in the application * root directory. * - * If the argument is an object, then it looks for `model`, `dataSources`, + * If the argument is an object, then it looks for `models`, `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. @@ -31,10 +31,15 @@ var addInstructionsToBrowserify = require('./lib/bundler'); * * 1. Creates DataSources from the `options.dataSources` object. * - * 2. Creates Models from the `options.models` object. + * 2. Configures 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()`. + * In both cases, the function loads JavaScript files in the + * `/boot` subdirectory of the application root directory with `require()`. + * + * **NOTE:** The version 2.0 of loopback-boot changed the way how models + * 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 `bootLoopBackApp(app, bootConfig)` and * `app.model(name, modelConfig)` in multiple @@ -47,19 +52,20 @@ var addInstructionsToBrowserify = require('./lib/bundler'); * @param app LoopBack application created by `loopback()`. * @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). + * @property {String} [appRootDir] Directory to use when loading JSON and + * JavaScript files. * Defaults to the current directory (`process.cwd()`). - * @property {Object} models Object containing `Model` definitions (optional). - * @property {Object} dataSources Object containing `DataSource` - * definitions (optional). - * @property {String} modelsRootDir Directory to use when loading `models.json` - * and `models/*.js`. Defaults to `appRootDir`. - * @property {String} datasourcesRootDir Directory to use when loading + * @property {Object} [models] Object containing `Model` configurations. + * @property {Object} [dataSources] Object containing `DataSource` definitions. + * @property {String} [modelsRootDir] Directory to use when loading + * `models.json`. Defaults to `appRootDir`. + * @property {String} [dsRootDir] Directory to use when loading * `datasources.json`. Defaults to `appRootDir`. - * @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 * `production`; however the applications are free to use any names. + * @property {Array.} [modelSources] List of directories where to look + * for files containing model definitions. * @end * * @header bootLoopBackApp(app, [options]) diff --git a/lib/bundler.js b/lib/bundler.js index d26b7fe..0e04525 100644 --- a/lib/bundler.js +++ b/lib/bundler.js @@ -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; } } diff --git a/lib/compiler.js b/lib/compiler.js index ac35dab..9f9b1ac 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -1,6 +1,7 @@ var assert = require('assert'); var fs = require('fs'); var path = require('path'); +var toposort = require('toposort'); var ConfigLoader = require('./config-loader'); var debug = require('debug')('loopback:boot:compiler'); @@ -26,13 +27,13 @@ module.exports = function compile(options) { var appRootDir = options.appRootDir = options.appRootDir || process.cwd(); var env = options.env || process.env.NODE_ENV || 'development'; - var appConfig = options.app || ConfigLoader.loadAppConfig(appRootDir, env); + var appConfig = options.config || ConfigLoader.loadAppConfig(appRootDir, env); assertIsValidConfig('app', appConfig); var modelsRootDir = options.modelsRootDir || appRootDir; var modelsConfig = options.models || ConfigLoader.loadModels(modelsRootDir, env); - assertIsValidConfig('model', modelsConfig); + assertIsValidModelConfig(modelsConfig); var dsRootDir = options.dsRootDir || appRootDir; var dataSourcesConfig = options.dataSources || @@ -40,15 +41,20 @@ module.exports = function compile(options) { assertIsValidConfig('data source', dataSourcesConfig); // require directories - var modelsScripts = findScripts(path.join(modelsRootDir, 'models')); var bootScripts = findScripts(path.join(appRootDir, 'boot')); + var modelsMeta = modelsConfig._meta || {}; + delete modelsConfig._meta; + + var modelSources = modelsMeta.sources || ['./models']; + var modelInstructions = buildAllModelInstructions( + modelsRootDir, modelsConfig, modelSources); + return { - app: appConfig, + config: appConfig, dataSources: dataSourcesConfig, - models: modelsConfig, + models: modelInstructions, files: { - models: modelsScripts, boot: bootScripts } }; @@ -61,6 +67,22 @@ function assertIsValidConfig(name, config) { } } +function assertIsValidModelConfig(config) { + assertIsValidConfig('model', config); + for (var name in config) { + var entry = config[name]; + var options = entry.options || {}; + var unsupported = entry.properties || + entry.base || options.base || + entry.plural || options.plural; + + if (unsupported) { + throw new Error( + 'The data in models.json is in the unsupported 1.x format.'); + } + } +} + /** * Find all javascript files (except for those prefixed with _) * and all directories. @@ -124,3 +146,139 @@ 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(); + + if (visited[name]) continue; + visited[name] = true; + result.push(name); + + var definition = registry[name] && registry[name].definition; + if (!definition) continue; + + var base = getBaseModelName(definition); + + // ignore built-in models like User + if (!registry[base]) continue; + + modelNames.push(base); + } + + return result; +} + +function getBaseModelName(modelDefinition) { + if (!modelDefinition) + return undefined; + + return modelDefinition.base || + modelDefinition.options && modelDefinition.options.base; +} + +function sortByInheritance(instructions) { + // create edges Base name -> Model name + var edges = instructions + .map(function(inst) { + return [getBaseModelName(inst.definition), inst.name]; + }); + + var sortedNames = toposort(edges); + + var instructionsByModelName = {}; + instructions.forEach(function(inst) { + instructionsByModelName[inst.name] = inst; + }); + + return sortedNames + // convert to instructions + .map(function(name) { + return instructionsByModelName[name]; + }) + // remove built-in models + .filter(function(inst) { + return !!inst; + }); +} + +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 + }; +} diff --git a/lib/config-loader.js b/lib/config-loader.js index 7cafb35..e218abd 100644 --- a/lib/config-loader.js +++ b/lib/config-loader.js @@ -10,7 +10,7 @@ var ConfigLoader = exports; * @returns {Object} */ ConfigLoader.loadAppConfig = function(rootDir, env) { - return loadNamed(rootDir, env, 'app', mergeAppConfig); + return loadNamed(rootDir, env, 'config', mergeAppConfig); }; /** diff --git a/lib/executor.js b/lib/executor.js index f68d9b5..8a238dd 100644 --- a/lib/executor.js +++ b/lib/executor.js @@ -23,7 +23,6 @@ module.exports = function execute(app, instructions) { setupDataSources(app, instructions); setupModels(app, instructions); - autoAttach(app); runBootScripts(app, instructions); @@ -66,7 +65,7 @@ function setHost(app, instructions) { process.env.OPENSHIFT_SLS_IP || process.env.OPENSHIFT_NODEJS_IP || process.env.HOST || - instructions.app.host || + instructions.config.host || process.env.npm_package_config_host || app.get('host'); @@ -83,7 +82,7 @@ function setPort(app, instructions) { process.env.OPENSHIFT_SLS_PORT, process.env.OPENSHIFT_NODEJS_PORT, process.env.PORT, - instructions.app.port, + instructions.config.port, process.env.npm_package_config_port, app.get('port'), 3000 @@ -99,7 +98,7 @@ function setPort(app, instructions) { function setApiRoot(app, instructions) { var restApiRoot = - instructions.app.restApiRoot || + instructions.config.restApiRoot || app.get('restApiRoot') || '/api'; @@ -112,7 +111,7 @@ function setApiRoot(app, instructions) { } function applyAppConfig(app, instructions) { - var appConfig = instructions.app; + var appConfig = instructions.config; for(var configKey in appConfig) { var cur = app.get(configKey); if(cur === undefined || cur === null) { @@ -128,11 +127,46 @@ function setupDataSources(app, instructions) { } function setupModels(app, instructions) { - forEachKeyedObject(instructions.models, function(key, obj) { - app.model(key, obj); - }); + defineModels(app, instructions); - runScripts(app, instructions.files.models); + instructions.models.forEach(function(data) { + // Skip base models that are not exported to the app + if (!data.config) return; + + app.model(data._model, data.config); + }); +} + +function defineModels(app, instructions) { + instructions.models.forEach(function(data) { + var name = data.name; + var model; + + if (!data.definition) { + model = app.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 = app.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); + } + } + } + + data._model = model; + }); } function forEachKeyedObject(obj, fn) { @@ -147,16 +181,11 @@ function runScripts(app, list) { if (!list || !list.length) return; list.forEach(function(filepath) { var exports = tryRequire(filepath); - if (isFunctionNotModelCtor(exports, app.loopback.Model)) + if (typeof exports === 'function') exports(app); }); } -function isFunctionNotModelCtor(fn, Model) { - return typeof fn === 'function' && - !(fn.prototype instanceof Model); -} - function tryRequire(modulePath) { try { return require.apply(this, arguments); @@ -170,19 +199,6 @@ function tryRequire(modulePath) { } } -// Deprecated, will be removed soon -function autoAttach(app) { - try { - app.loopback.autoAttach(); - } catch(e) { - if(e.name === 'AssertionError') { - console.warn(e); - } else { - throw e; - } - } -} - function runBootScripts(app, instructions) { runScripts(app, instructions.files.boot); } @@ -192,7 +208,7 @@ function enableAnonymousSwagger(app, instructions) { var swagger = app.remotes().exports.swagger; if (!swagger) return; - var appConfig = instructions.app; + var appConfig = instructions.config; var requireTokenForSwagger = appConfig.swagger && appConfig.swagger.requireToken; swagger.requireToken = requireTokenForSwagger || false; diff --git a/package.json b/package.json index 21522bb..bf62d07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loopback-boot", - "version": "1.1.0", + "version": "2.0.0-beta1", "description": "Convention-based bootstrapper for LoopBack applications", "keywords": [ "StrongLoop", @@ -26,6 +26,7 @@ "commondir": "0.0.1", "debug": "^0.8.1", "semver": "^2.3.0", + "toposort": "^0.2.10", "underscore": "^1.6.0" }, "devDependencies": { diff --git a/test/browser.test.js b/test/browser.test.js index 31ddf94..9395967 100644 --- a/test/browser.test.js +++ b/test/browser.test.js @@ -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) { diff --git a/test/compiler.test.js b/test/compiler.test.js index b1076b0..ca328b0 100644 --- a/test/compiler.test.js +++ b/test/compiler.test.js @@ -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'); @@ -16,7 +15,7 @@ describe('compiler', function() { var options, instructions, appConfig; beforeEach(function() { options = { - app: { + config: { port: 3000, host: '127.0.0.1', restApiRoot: '/rest-api', @@ -25,9 +24,6 @@ describe('compiler', function() { }, models: { 'foo-bar-bat-baz': { - options: { - plural: 'foo-bar-bat-bazzies' - }, dataSource: 'the-db' } }, @@ -39,7 +35,7 @@ describe('compiler', function() { } }; instructions = boot.compile(options); - appConfig = instructions.app; + appConfig = instructions.config; }); it('has port setting', function() { @@ -62,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() { @@ -73,8 +77,16 @@ describe('compiler', function() { describe('from directory', function() { it('loads config files', function() { var instructions = boot.compile(SIMPLE_APP); - assert(instructions.models.foo); - assert(instructions.models.foo.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() { @@ -136,13 +148,14 @@ describe('compiler', function() { it('merges app configs from multiple files', function() { appdir.createConfigFilesSync(); - appdir.writeConfigFileSync('app.local.json', { cfgLocal: 'applied' }); + appdir.writeConfigFileSync('config.local.json', { cfgLocal: 'applied' }); var env = process.env.NODE_ENV || 'development'; - appdir.writeConfigFileSync('app.' + env + '.json', { cfgEnv: 'applied' }); + appdir.writeConfigFileSync('config.' + env + '.json', + { cfgEnv: 'applied' }); var instructions = boot.compile(appdir.PATH); - var appConfig = instructions.app; + var appConfig = instructions.config; expect(appConfig).to.have.property('cfgLocal', 'applied'); expect(appConfig).to.have.property('cfgEnv', 'applied'); @@ -157,11 +170,11 @@ describe('compiler', function() { it('supports .js for custom app config files', function() { appdir.createConfigFilesSync(); - appdir.writeFileSync('app.local.js', + appdir.writeFileSync('config.local.js', 'module.exports = { fromJs: true };'); var instructions = boot.compile(appdir.PATH); - var appConfig = instructions.app; + var appConfig = instructions.config; expect(appConfig).to.have.property('fromJs', true); }); @@ -189,15 +202,13 @@ describe('compiler', function() { foo: { dataSource: 'db' } }); - var fooJs = appdir.writeFileSync('custom/models/foo.js', ''); - var instructions = boot.compile({ appRootDir: appdir.PATH, modelsRootDir: path.resolve(appdir.PATH, 'custom') }); - expect(instructions.models).to.have.property('foo'); - expect(instructions.files.models).to.eql([fooJs]); + expect(instructions.models).to.have.length(1); + expect(instructions.models[0]).to.have.property('name', 'foo'); }); it('includes boot/*.js scripts', function() { @@ -208,13 +219,192 @@ describe('compiler', function() { expect(instructions.files.boot).to.eql([initJs]); }); - it('supports models/ subdirectires that are not require()able', function() { + it('ignores models/ subdirectory', function() { appdir.createConfigFilesSync(); - appdir.writeFileSync('models/test/model.test.js', - 'throw new Error("should not been called");'); + appdir.writeFileSync('models/my-model.js', ''); + var instructions = boot.compile(appdir.PATH); - expect(instructions.files.models).to.eql([]); + expect(instructions.files).to.not.have.property('models'); + }); + + it('throws when models.json contains `properties` from 1.x', function() { + appdir.createConfigFilesSync({}, {}, { + foo: { properties: { name: 'string' } } + }); + + expect(function() { boot.compile(appdir.PATH); }) + .to.throw(/unsupported 1\.x format/); + }); + + it('throws when models.json contains `options.base` from 1.x', function() { + appdir.createConfigFilesSync({}, {}, { + Customer: { options: { base: 'User' } } + }); + + expect(function() { boot.compile(appdir.PATH); }) + .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 `sources` option in `models.json`', function() { + appdir.createConfigFilesSync({}, {}, { + _meta: { + sources: ['./custom-models'] + }, + Car: { dataSource: 'db' } + }); + appdir.writeConfigFileSync('custom-models/car.json', { name: 'Car' }); + appdir.writeFileSync('custom-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, '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']); + }); + + it('sorts models, base models first', function() { + appdir.createConfigFilesSync({}, {}, { + Vehicle: { dataSource: 'db' }, + FlyingCar: { dataSource: 'db' }, + Car: { dataSource: 'db' } + }); + appdir.writeConfigFileSync('models/car.json', { + name: 'Car', + base: 'Vehicle' + }); + appdir.writeConfigFileSync('models/vehicle.json', { + name: 'Vehicle' + }); + appdir.writeConfigFileSync('models/flying-car.json', { + name: 'FlyingCar', + base: 'Car' + }); + + var instructions = boot.compile(appdir.PATH); + + var modelNames = instructions.models.map(getNameProperty); + expect(modelNames).to.eql(['Vehicle', 'Car', 'FlyingCar']); + }); + + it('detects circular Model dependencies', function() { + appdir.createConfigFilesSync({}, {}, { + Vehicle: { dataSource: 'db' }, + Car: { dataSource: 'db' } + }); + appdir.writeConfigFileSync('models/car.json', { + name: 'Car', + base: 'Vehicle' + }); + appdir.writeConfigFileSync('models/vehicle.json', { + name: 'Vehicle', + base: 'Car' + }); + + expect(function() { boot.compile(appdir.PATH); }) + .to.throw(/cyclic dependency/i); }); }); }); + +function getNameProperty(obj) { + return obj.name; +} diff --git a/test/executor.test.js b/test/executor.test.js index 1c64e95..08cddec 100644 --- a/test/executor.test.js +++ b/test/executor.test.js @@ -10,7 +10,6 @@ var SIMPLE_APP = path.join(__dirname, 'fixtures', 'simple-app'); var app; - describe('executor', function() { beforeEach(sandbox.reset); @@ -21,21 +20,21 @@ describe('executor', function() { }); var dummyInstructions = someInstructions({ - app: { + config: { 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' + models: [ + { + name: 'User', + config: { + dataSource: 'the-db' + } } - }, + ], dataSources: { 'the-db': { connector: 'memory', @@ -44,19 +43,101 @@ describe('executor', function() { } }); - it('instantiates models', function() { + it('configures models', function() { boot.execute(app, dummyInstructions); 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'); + assert(app.models.User); + assert.equal(app.models.User, loopback.User, + 'Boot should not have extended loopback.User model'); + assertValidDataSource(app.models.User.dataSource); + assert.isFunc(app.models.User, 'find'); + 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({ + 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({ + 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.FooBarBatBaz.dataSource, app.dataSources.theDb); + assert.equal(app.models.User.dataSource, app.dataSources.theDb); + }); + + it('defines all models first before running the config phase', function() { + appdir.writeFileSync('models/Customer.js', 'module.exports = ' + + function(Customer/*, Base*/) { + Customer.on('attached', function() { + Customer._modelsWhenAttached = + Object.keys(Customer.modelBuilder.models); + }); + }.toString()); + + boot.execute(app, someInstructions({ + models: [ + { + name: 'Customer', + config: { dataSource: 'db' }, + definition: { name: 'Customer' }, + sourceFile: path.resolve(appdir.PATH, 'models', 'Customer.js') + }, + { + name: 'UniqueName', + config: { dataSource: 'db' }, + definition: { name: 'UniqueName' }, + sourceFile: undefined + } + ] + })); + + expect(app.models.Customer._modelsWhenAttached).to.include('UniqueName'); }); it('instantiates data sources', function() { @@ -67,6 +148,17 @@ describe('executor', function() { assert(app.dataSources.TheDb); }); + it('does not call autoAttach', function() { + boot.execute(app, dummyInstructions); + + // loopback-datasource-juggler quirk: + // Model.dataSources has modelBuilder as the default value, + // therefore it's not enough to assert a false-y value + var actual = loopback.Email.dataSource instanceof loopback.DataSource ? + 'attached' : 'not attached'; + expect(actual).to.equal('not attached'); + }); + describe('with boot and models files', function() { beforeEach(function() { boot.execute(app, simpleAppInstructions()); @@ -76,18 +168,13 @@ describe('executor', function() { assert(process.loadedFooJS); delete process.loadedFooJS; }); - - it('should run `models/*` files', function() { - assert(process.loadedBarJS); - delete process.loadedBarJS; - }); }); describe('with PaaS and npm env variables', function() { function bootWithDefaults() { app = loopback(); boot.execute(app, someInstructions({ - app: { + config: { port: undefined, host: undefined } @@ -155,25 +242,16 @@ describe('executor', function() { } it('should honor 0 for free port', function() { - boot.execute(app, someInstructions({ app: { port: 0 } })); + boot.execute(app, someInstructions({ config: { port: 0 } })); assert.equal(app.get('port'), 0); }); it('should default to port 3000', function() { - boot.execute(app, someInstructions({ app: { port: undefined } })); + boot.execute(app, someInstructions({ config: { port: undefined } })); assert.equal(app.get('port'), 3000); }); }); - it('calls function exported by models/model.js', function() { - var file = appdir.writeFileSync('models/model.js', - 'module.exports = function(app) { app.fnCalled = true; };'); - - delete app.fnCalled; - boot.execute(app, someInstructions({ files: { models: [ file ] } })); - expect(app.fnCalled, 'exported fn was called').to.be.true(); - }); - it('calls function exported by boot/init.js', function() { var file = appdir.writeFileSync('boot/init.js', 'module.exports = function(app) { app.fnCalled = true; };'); @@ -182,19 +260,6 @@ describe('executor', function() { boot.execute(app, someInstructions({ files: { boot: [ file ] } })); expect(app.fnCalled, 'exported fn was called').to.be.true(); }); - - it('does not call Model ctor exported by models/model.json', function() { - var file = appdir.writeFileSync('models/model.js', - 'var loopback = require("loopback");\n' + - 'module.exports = loopback.Model.extend("foo");\n' + - 'module.exports.prototype._initProperties = function() {\n' + - ' global.fnCalled = true;\n' + - '};'); - - delete global.fnCalled; - boot.execute(app, someInstructions({ files: { models: [ file ] } })); - expect(global.fnCalled, 'exported fn was called').to.be.undefined(); - }); }); @@ -217,11 +282,10 @@ assert.isFunc = function (obj, name) { function someInstructions(values) { var result = { - app: values.app || {}, - models: values.models || {}, - dataSources: values.dataSources || {}, + config: values.config || {}, + models: values.models || [], + dataSources: values.dataSources || { db: { connector: 'memory' } }, files: { - models: [], boot: [] } }; diff --git a/test/fixtures/browser-app/datasources.json b/test/fixtures/browser-app/datasources.json new file mode 100644 index 0000000..618fd1f --- /dev/null +++ b/test/fixtures/browser-app/datasources.json @@ -0,0 +1,5 @@ +{ + "db": { + "connector": "remote" + } +} diff --git a/test/fixtures/browser-app/models.json b/test/fixtures/browser-app/models.json new file mode 100644 index 0000000..3566c55 --- /dev/null +++ b/test/fixtures/browser-app/models.json @@ -0,0 +1,5 @@ +{ + "Customer": { + "dataSource": "db" + } +} diff --git a/test/fixtures/browser-app/models/customer.js b/test/fixtures/browser-app/models/customer.js new file mode 100644 index 0000000..dfb143e --- /dev/null +++ b/test/fixtures/browser-app/models/customer.js @@ -0,0 +1,4 @@ +module.exports = function(Customer, Base) { + Customer.settings._customized = 'Customer'; + Base.settings._customized = 'Base'; +}; diff --git a/test/fixtures/browser-app/models/customer.json b/test/fixtures/browser-app/models/customer.json new file mode 100644 index 0000000..c1df7a5 --- /dev/null +++ b/test/fixtures/browser-app/models/customer.json @@ -0,0 +1,4 @@ +{ + "name": "Customer", + "base": "User" +} diff --git a/test/fixtures/simple-app/app.json b/test/fixtures/simple-app/config.json similarity index 100% rename from test/fixtures/simple-app/app.json rename to test/fixtures/simple-app/config.json diff --git a/test/fixtures/simple-app/models.json b/test/fixtures/simple-app/models.json index 3a22f13..0468a66 100644 --- a/test/fixtures/simple-app/models.json +++ b/test/fixtures/simple-app/models.json @@ -1,5 +1,5 @@ { - "foo": { + "User": { "dataSource": "db" } } diff --git a/test/fixtures/simple-app/models/bar.js b/test/fixtures/simple-app/models/bar.js deleted file mode 100644 index 0eef5d9..0000000 --- a/test/fixtures/simple-app/models/bar.js +++ /dev/null @@ -1 +0,0 @@ -process.loadedBarJS = true; diff --git a/test/global-setup.js b/test/global-setup.js deleted file mode 100644 index 1152607..0000000 --- a/test/global-setup.js +++ /dev/null @@ -1,11 +0,0 @@ -var loopback = require('loopback'); - -// bootLoopBackApp() calls loopback.autoAttach -// which attempts to attach all models to default datasources -// one of those models is Email which requires 'email' datasource -loopback.setDefaultDataSourceForType('mail', { - connector: loopback.Mail, - transports: [ - {type: 'STUB'} - ] -}); diff --git a/test/helpers/appdir.js b/test/helpers/appdir.js index 4db2e10..4961d07 100644 --- a/test/helpers/appdir.js +++ b/test/helpers/appdir.js @@ -22,7 +22,7 @@ appdir.init = function(cb) { appdir.createConfigFilesSync = function(appConfig, dataSources, models) { appConfig = extend({ }, appConfig); - appdir.writeConfigFileSync ('app.json', appConfig); + appdir.writeConfigFileSync ('config.json', appConfig); dataSources = extend({ db: {