From 1a4e8863ef1bb2104964146931017f04fd5d94bf Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 5 Aug 2014 16:16:10 +0200 Subject: [PATCH 1/4] Basic plugin architecture Similar to http://mongoosejs.com/docs/plugins.html Next, loopback-boot should be updated to support loading plugins from dirs. --- index.js | 1 + lib/model-builder.js | 17 ++++++++++ lib/model.js | 5 +++ lib/plugins.js | 69 +++++++++++++++++++++++++++++++++++++++ lib/plugins/timestamps.js | 23 +++++++++++++ test/plugins.test.js | 44 +++++++++++++++++++++++++ 6 files changed, 159 insertions(+) create mode 100644 lib/plugins.js create mode 100644 lib/plugins/timestamps.js create mode 100644 test/plugins.test.js diff --git a/index.js b/index.js index a1e5420e..eca8ba9b 100644 --- a/index.js +++ b/index.js @@ -3,6 +3,7 @@ exports.DataSource = exports.Schema = require('./lib/datasource.js').DataSource; exports.ModelBaseClass = require('./lib/model.js'); exports.GeoPoint = require('./lib/geo.js').GeoPoint; exports.ValidationError = require('./lib/validations.js').ValidationError; +exports.plugins = require('./lib/plugins'); exports.__defineGetter__('version', function () { return require('./package.json').version; diff --git a/lib/model-builder.js b/lib/model-builder.js index aa052dea..9fef88b6 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -10,6 +10,7 @@ var DefaultModelBaseClass = require('./model.js'); var List = require('./list.js'); var ModelDefinition = require('./model-definition.js'); var mergeSettings = require('./utils').mergeSettings; +var plugins = require('./plugins'); // Set up types require('./types')(ModelBuilder); @@ -428,6 +429,22 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett ModelClass.registerProperty(propertyName); } + var pluginSettings = settings.plugins || {}; + var keys = Object.keys(pluginSettings); + var size = keys.length; + for (i = 0; i < size; i++) { + var name = keys[i]; + var plugin = pluginSettings[name]; + if (plugin === true) plugin = {}; + if (typeof plugin === 'object') { + pluginSettings[name] = true; + plugins.apply(name, ModelClass, plugin); + } else { + // for settings metadata + pluginSettings[name] = false; + } + } + ModelClass.emit('defined', ModelClass); return ModelClass; diff --git a/lib/model.js b/lib/model.js index 51a59397..1709a226 100644 --- a/lib/model.js +++ b/lib/model.js @@ -12,6 +12,7 @@ var jutil = require('./jutil'); var List = require('./list'); var Hookable = require('./hooks'); var validations = require('./validations.js'); +var plugins = require('./plugins'); // Set up an object for quick lookup var BASE_TYPES = { @@ -402,5 +403,9 @@ ModelBaseClass.prototype.setStrict = function (strict) { this.__strict = strict; }; +ModelBaseClass.plugin = function (name, options) { + plugins.apply(name, this, options); +}; + jutil.mixin(ModelBaseClass, Hookable); jutil.mixin(ModelBaseClass, validations.Validatable); diff --git a/lib/plugins.js b/lib/plugins.js new file mode 100644 index 00000000..9788aed6 --- /dev/null +++ b/lib/plugins.js @@ -0,0 +1,69 @@ +var fs = require('fs'); +var path = require('path'); +var debug = require('debug')('loopback:plugin'); + +var registry = {}; + +exports.apply = function applyPlugin(name, modelClass, options) { + var fn = registry[name]; + if (typeof fn === 'function') { + if (modelClass.dataSource) { + fn(modelClass, options || {}); + } else { + modelClass.once('dataSourceAttached', function() { + fn(modelClass, options || {}); + }); + } + } else { + debug('Invalid plugin: %s', name); + } +}; + +var definePlugin = exports.define = function definePlugin(name, fn) { + if (typeof fn === 'function') { + if (registry[name]) { + debug('Overwriting plugin: %s', name); + } else { + debug('Defined plugin: %s', name); + } + registry[name] = fn; + } else { + debug('Invalid plugin function: %s', name); + } +}; + +var loadPlugin = exports.load = function loadPlugin(dir) { + var files = tryReadDir(path.resolve(dir)); + files.forEach(function(filename) { + var filepath = path.resolve(path.join(dir, filename)); + var ext = path.extname(filename); + var name = path.basename(filename, ext); + var stats = fs.statSync(filepath); + if (stats.isFile()) { + if (ext in require.extensions) { + var plugin = tryRequire(filepath); + if (typeof plugin === 'function') { + definePlugin(name, plugin); + } + } + } + }); +}; + +loadPlugin(path.join(__dirname, 'plugins')); + +function tryReadDir() { + try { + return fs.readdirSync.apply(fs, arguments); + } catch(e) { + return []; + } +}; + +function tryRequire(file) { + try { + return require(file); + } catch(e) { + } +}; + diff --git a/lib/plugins/timestamps.js b/lib/plugins/timestamps.js new file mode 100644 index 00000000..91a4af0f --- /dev/null +++ b/lib/plugins/timestamps.js @@ -0,0 +1,23 @@ +module.exports = function timestamps(Model, options) { + + Model.defineProperty('createdAt', { type: 'date' }); + Model.defineProperty('updatedAt', { type: 'date' }); + + var originalBeforeSave = Model.beforeSave; + Model.beforeSave = function(next, data) { + Model.applyTimestamps(data, this.isNewRecord()); + if (data.createdAt) this.createdAt = data.createdAt; + if (data.updatedAt) this.updatedAt = data.updatedAt; + if (originalBeforeSave) { + originalBeforeSave.apply(this, arguments); + } else { + next(); + } + }; + + Model.applyTimestamps = function(data, creation) { + data.updatedAt = new Date(); + if (creation) data.createdAt = data.updatedAt; + }; + +}; \ No newline at end of file diff --git a/test/plugins.test.js b/test/plugins.test.js new file mode 100644 index 00000000..353d0046 --- /dev/null +++ b/test/plugins.test.js @@ -0,0 +1,44 @@ +// This test written in mocha+should.js +var should = require('./init.js'); +var assert = require('assert'); + +var jdb = require('../'); +var ModelBuilder = jdb.ModelBuilder; +var DataSource = jdb.DataSource; +var Memory = require('../lib/connectors/memory'); + +var plugins = jdb.plugins; + +describe('Model class', function () { + + it('should define a plugin', function() { + plugins.define('example', function(Model, options) { + Model.prototype.example = function() { + return options; + }; + }); + }) + + it('should apply plugin', function(done) { + var memory = new DataSource({connector: Memory}); + var Item = memory.createModel('Item', { name: 'string' }, { + plugins: { timestamps: true } + }); + + Item.plugin('example', { foo: 'bar' }); + + var def = memory.getModelDefinition('Item'); + var json = def.toJSON(); + var properties = json.properties; + properties.createdAt.should.eql({ type: 'Date' }); + properties.updatedAt.should.eql({ type: 'Date' }); + + Item.create({ name: 'Item 1' }, function(err, inst) { + inst.createdAt.should.be.a.date; + inst.updatedAt.should.be.a.date; + inst.example().should.eql({ foo: 'bar' }); + done(); + }); + }); + +}); \ No newline at end of file From f1f692a8a595ee166d2837ac50b13cfd44198343 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 5 Aug 2014 16:26:34 +0200 Subject: [PATCH 2/4] Minor touch-ups --- lib/plugins.js | 4 ++-- lib/plugins/timestamps.js | 2 +- test/plugins.test.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/plugins.js b/lib/plugins.js index 9788aed6..77129dc8 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -22,9 +22,9 @@ exports.apply = function applyPlugin(name, modelClass, options) { var definePlugin = exports.define = function definePlugin(name, fn) { if (typeof fn === 'function') { if (registry[name]) { - debug('Overwriting plugin: %s', name); + debug('Duplicate plugin: %s', name); } else { - debug('Defined plugin: %s', name); + debug('Defining plugin: %s', name); } registry[name] = fn; } else { diff --git a/lib/plugins/timestamps.js b/lib/plugins/timestamps.js index 91a4af0f..b6ee77d9 100644 --- a/lib/plugins/timestamps.js +++ b/lib/plugins/timestamps.js @@ -20,4 +20,4 @@ module.exports = function timestamps(Model, options) { if (creation) data.createdAt = data.updatedAt; }; -}; \ No newline at end of file +}; diff --git a/test/plugins.test.js b/test/plugins.test.js index 353d0046..847122d1 100644 --- a/test/plugins.test.js +++ b/test/plugins.test.js @@ -41,4 +41,4 @@ describe('Model class', function () { }); }); -}); \ No newline at end of file +}); From 455ee9ec63ff0986b32e3a0fdb8827eb10496322 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Tue, 5 Aug 2014 16:28:17 +0200 Subject: [PATCH 3/4] Fix typo: loadPlugin(s) --- lib/plugins.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plugins.js b/lib/plugins.js index 77129dc8..32b10aa9 100644 --- a/lib/plugins.js +++ b/lib/plugins.js @@ -32,7 +32,7 @@ var definePlugin = exports.define = function definePlugin(name, fn) { } }; -var loadPlugin = exports.load = function loadPlugin(dir) { +var loadPlugins = exports.load = function loadPlugins(dir) { var files = tryReadDir(path.resolve(dir)); files.forEach(function(filename) { var filepath = path.resolve(path.join(dir, filename)); @@ -50,7 +50,7 @@ var loadPlugin = exports.load = function loadPlugin(dir) { }); }; -loadPlugin(path.join(__dirname, 'plugins')); +loadPlugins(path.join(__dirname, 'plugins')); function tryReadDir() { try { From 35776311fd47eeaecd73dc5922d6b2decfeca5d8 Mon Sep 17 00:00:00 2001 From: Fabien Franzen Date: Wed, 6 Aug 2014 13:26:47 +0200 Subject: [PATCH 4/4] Unified plugins into mixins Mixin types: module function, module object, LDL json object. --- index.js | 2 +- lib/mixins.js | 87 +++++++++++++++++++ .../timestamps.js => mixins/time-stamp.js} | 0 lib/model-builder.js | 18 ++-- lib/model.js | 12 +-- lib/plugins.js | 69 --------------- test/fixtures/mixins/address.json | 12 +++ test/fixtures/mixins/demo.js | 5 ++ test/fixtures/mixins/other.js | 3 + test/mixins.test.js | 82 +++++++++++++++++ test/plugins.test.js | 44 ---------- 11 files changed, 205 insertions(+), 129 deletions(-) create mode 100644 lib/mixins.js rename lib/{plugins/timestamps.js => mixins/time-stamp.js} (100%) delete mode 100644 lib/plugins.js create mode 100644 test/fixtures/mixins/address.json create mode 100644 test/fixtures/mixins/demo.js create mode 100644 test/fixtures/mixins/other.js create mode 100644 test/mixins.test.js delete mode 100644 test/plugins.test.js diff --git a/index.js b/index.js index eca8ba9b..de88d0ab 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,9 @@ +exports.mixins = require('./lib/mixins'); // require before ModelBuilder below - why? exports.ModelBuilder = exports.LDL = require('./lib/model-builder.js').ModelBuilder; exports.DataSource = exports.Schema = require('./lib/datasource.js').DataSource; exports.ModelBaseClass = require('./lib/model.js'); exports.GeoPoint = require('./lib/geo.js').GeoPoint; exports.ValidationError = require('./lib/validations.js').ValidationError; -exports.plugins = require('./lib/plugins'); exports.__defineGetter__('version', function () { return require('./package.json').version; diff --git a/lib/mixins.js b/lib/mixins.js new file mode 100644 index 00000000..2314a0ae --- /dev/null +++ b/lib/mixins.js @@ -0,0 +1,87 @@ +var fs = require('fs'); +var path = require('path'); +var extend = require('util')._extend; +var inflection = require('inflection'); +var debug = require('debug')('loopback:mixin'); +var ModelBuilder = require('./model-builder').ModelBuilder; + +var registry = exports.registry = {}; +var modelBuilder = new ModelBuilder(); + +exports.apply = function applyMixin(modelClass, name, options) { + name = inflection.classify(name.replace(/-/g, '_')); + var fn = registry[name]; + if (typeof fn === 'function') { + if (modelClass.dataSource) { + fn(modelClass, options || {}); + } else { + modelClass.once('dataSourceAttached', function() { + fn(modelClass, options || {}); + }); + } + } else { + debug('Invalid mixin: %s', name); + } +}; + +var defineMixin = exports.define = function defineMixin(name, mixin, ldl) { + if (typeof mixin === 'function' || typeof mixin === 'object') { + name = inflection.classify(name.replace(/-/g, '_')); + if (registry[name]) { + debug('Duplicate mixin: %s', name); + } else { + debug('Defining mixin: %s', name); + } + if (typeof mixin === 'object' && ldl) { + var model = modelBuilder.define(name, mixin); + registry[name] = function(Model, options) { + Model.mixin(model, options); + }; + } else if (typeof mixin === 'object') { + registry[name] = function(Model, options) { + extend(Model.prototype, mixin); + }; + } else if (typeof mixin === 'function') { + registry[name] = mixin; + } + } else { + debug('Invalid mixin function: %s', name); + } +}; + +var loadMixins = exports.load = function loadMixins(dir) { + var files = tryReadDir(path.resolve(dir)); + files.forEach(function(filename) { + var filepath = path.resolve(path.join(dir, filename)); + var ext = path.extname(filename); + var name = path.basename(filename, ext); + var stats = fs.statSync(filepath); + if (stats.isFile()) { + if (ext in require.extensions) { + var mixin = tryRequire(filepath); + if (typeof mixin === 'function' + || typeof mixin === 'object') { + defineMixin(name, mixin, ext === '.json'); + } + } + } + }); +}; + +loadMixins(path.join(__dirname, 'mixins')); + +function tryReadDir() { + try { + return fs.readdirSync.apply(fs, arguments); + } catch(e) { + return []; + } +}; + +function tryRequire(file) { + try { + return require(file); + } catch(e) { + } +}; + diff --git a/lib/plugins/timestamps.js b/lib/mixins/time-stamp.js similarity index 100% rename from lib/plugins/timestamps.js rename to lib/mixins/time-stamp.js diff --git a/lib/model-builder.js b/lib/model-builder.js index 9fef88b6..e46ce3a6 100644 --- a/lib/model-builder.js +++ b/lib/model-builder.js @@ -10,7 +10,7 @@ var DefaultModelBaseClass = require('./model.js'); var List = require('./list.js'); var ModelDefinition = require('./model-definition.js'); var mergeSettings = require('./utils').mergeSettings; -var plugins = require('./plugins'); +var mixins = require('./mixins'); // Set up types require('./types')(ModelBuilder); @@ -429,19 +429,19 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett ModelClass.registerProperty(propertyName); } - var pluginSettings = settings.plugins || {}; - var keys = Object.keys(pluginSettings); + var mixinSettings = settings.mixins || {}; + var keys = Object.keys(mixinSettings); var size = keys.length; for (i = 0; i < size; i++) { var name = keys[i]; - var plugin = pluginSettings[name]; - if (plugin === true) plugin = {}; - if (typeof plugin === 'object') { - pluginSettings[name] = true; - plugins.apply(name, ModelClass, plugin); + var mixin = mixinSettings[name]; + if (mixin === true) mixin = {}; + if (typeof mixin === 'object') { + mixinSettings[name] = true; + mixins.apply(ModelClass, name, mixin); } else { // for settings metadata - pluginSettings[name] = false; + mixinSettings[name] = false; } } diff --git a/lib/model.js b/lib/model.js index 1709a226..678226d3 100644 --- a/lib/model.js +++ b/lib/model.js @@ -12,7 +12,7 @@ var jutil = require('./jutil'); var List = require('./list'); var Hookable = require('./hooks'); var validations = require('./validations.js'); -var plugins = require('./plugins'); +var mixins = require('./mixins'); // Set up an object for quick lookup var BASE_TYPES = { @@ -388,7 +388,11 @@ ModelBaseClass.prototype.inspect = function () { }; ModelBaseClass.mixin = function (anotherClass, options) { - return jutil.mixin(this, anotherClass, options); + if (typeof anotherClass === 'string') { + mixins.apply(this, anotherClass, options); + } else { + return jutil.mixin(this, anotherClass, options); + } }; ModelBaseClass.prototype.getDataSource = function () { @@ -403,9 +407,5 @@ ModelBaseClass.prototype.setStrict = function (strict) { this.__strict = strict; }; -ModelBaseClass.plugin = function (name, options) { - plugins.apply(name, this, options); -}; - jutil.mixin(ModelBaseClass, Hookable); jutil.mixin(ModelBaseClass, validations.Validatable); diff --git a/lib/plugins.js b/lib/plugins.js deleted file mode 100644 index 32b10aa9..00000000 --- a/lib/plugins.js +++ /dev/null @@ -1,69 +0,0 @@ -var fs = require('fs'); -var path = require('path'); -var debug = require('debug')('loopback:plugin'); - -var registry = {}; - -exports.apply = function applyPlugin(name, modelClass, options) { - var fn = registry[name]; - if (typeof fn === 'function') { - if (modelClass.dataSource) { - fn(modelClass, options || {}); - } else { - modelClass.once('dataSourceAttached', function() { - fn(modelClass, options || {}); - }); - } - } else { - debug('Invalid plugin: %s', name); - } -}; - -var definePlugin = exports.define = function definePlugin(name, fn) { - if (typeof fn === 'function') { - if (registry[name]) { - debug('Duplicate plugin: %s', name); - } else { - debug('Defining plugin: %s', name); - } - registry[name] = fn; - } else { - debug('Invalid plugin function: %s', name); - } -}; - -var loadPlugins = exports.load = function loadPlugins(dir) { - var files = tryReadDir(path.resolve(dir)); - files.forEach(function(filename) { - var filepath = path.resolve(path.join(dir, filename)); - var ext = path.extname(filename); - var name = path.basename(filename, ext); - var stats = fs.statSync(filepath); - if (stats.isFile()) { - if (ext in require.extensions) { - var plugin = tryRequire(filepath); - if (typeof plugin === 'function') { - definePlugin(name, plugin); - } - } - } - }); -}; - -loadPlugins(path.join(__dirname, 'plugins')); - -function tryReadDir() { - try { - return fs.readdirSync.apply(fs, arguments); - } catch(e) { - return []; - } -}; - -function tryRequire(file) { - try { - return require(file); - } catch(e) { - } -}; - diff --git a/test/fixtures/mixins/address.json b/test/fixtures/mixins/address.json new file mode 100644 index 00000000..b91ff47b --- /dev/null +++ b/test/fixtures/mixins/address.json @@ -0,0 +1,12 @@ +{ + "properties": { + "street": { + "type": "string", + "required": true + }, + "city": { + "type": "string", + "required": true + } + } +} \ No newline at end of file diff --git a/test/fixtures/mixins/demo.js b/test/fixtures/mixins/demo.js new file mode 100644 index 00000000..8971ad1a --- /dev/null +++ b/test/fixtures/mixins/demo.js @@ -0,0 +1,5 @@ +module.exports = function timestamps(Model, options) { + + Model.demoMixin = options.ok; + +}; diff --git a/test/fixtures/mixins/other.js b/test/fixtures/mixins/other.js new file mode 100644 index 00000000..b0656196 --- /dev/null +++ b/test/fixtures/mixins/other.js @@ -0,0 +1,3 @@ +module.exports = { + otherMixin: true +} \ No newline at end of file diff --git a/test/mixins.test.js b/test/mixins.test.js new file mode 100644 index 00000000..afa88887 --- /dev/null +++ b/test/mixins.test.js @@ -0,0 +1,82 @@ +// This test written in mocha+should.js +var should = require('./init.js'); +var assert = require('assert'); +var path = require('path'); + +var jdb = require('../'); +var ModelBuilder = jdb.ModelBuilder; +var DataSource = jdb.DataSource; +var Memory = require('../lib/connectors/memory'); + +var mixins = jdb.mixins; + +describe('Model class', function () { + + it('should define a mixin', function() { + mixins.define('Example', function(Model, options) { + Model.prototype.example = function() { + return options; + }; + }); + }); + + it('should load mixins from directory', function() { + var expected = [ 'TimeStamp', 'Example', 'Address', 'Demo', 'Other' ]; + mixins.load(path.join(__dirname, 'fixtures', 'mixins')); + mixins.registry.should.have.property('TimeStamp'); + mixins.registry.should.have.property('Example'); + mixins.registry.should.have.property('Address'); + mixins.registry.should.have.property('Demo'); + mixins.registry.should.have.property('Other'); + }); + + it('should apply a mixin class', function() { + var memory = new DataSource({connector: Memory}); + var Item = memory.createModel('Item', { name: 'string' }, { + mixins: { TimeStamp: true, demo: true, Address: true } + }); + + var modelBuilder = new ModelBuilder(); + var Address = modelBuilder.define('Address', { + street: { type: 'string', required: true }, + city: { type: 'string', required: true } + }); + + Item.mixin(Address); + + var def = memory.getModelDefinition('Item'); + var properties = def.toJSON().properties; + + // properties.street.should.eql({ type: 'String', required: true }); + // properties.city.should.eql({ type: 'String', required: true }); + }); + + it('should apply mixins', function(done) { + var memory = new DataSource({connector: Memory}); + var Item = memory.createModel('Item', { name: 'string' }, { + mixins: { TimeStamp: true, demo: { ok: true }, Address: true } + }); + + Item.mixin('Example', { foo: 'bar' }); + Item.mixin('other'); + + var def = memory.getModelDefinition('Item'); + var properties = def.toJSON().properties; + properties.createdAt.should.eql({ type: 'Date' }); + properties.updatedAt.should.eql({ type: 'Date' }); + + // properties.street.should.eql({ type: 'String', required: true }); + // properties.city.should.eql({ type: 'String', required: true }); + + Item.demoMixin.should.be.true; + Item.prototype.otherMixin.should.be.true; + + Item.create({ name: 'Item 1' }, function(err, inst) { + inst.createdAt.should.be.a.date; + inst.updatedAt.should.be.a.date; + inst.example().should.eql({ foo: 'bar' }); + done(); + }); + }); + +}); diff --git a/test/plugins.test.js b/test/plugins.test.js deleted file mode 100644 index 847122d1..00000000 --- a/test/plugins.test.js +++ /dev/null @@ -1,44 +0,0 @@ -// This test written in mocha+should.js -var should = require('./init.js'); -var assert = require('assert'); - -var jdb = require('../'); -var ModelBuilder = jdb.ModelBuilder; -var DataSource = jdb.DataSource; -var Memory = require('../lib/connectors/memory'); - -var plugins = jdb.plugins; - -describe('Model class', function () { - - it('should define a plugin', function() { - plugins.define('example', function(Model, options) { - Model.prototype.example = function() { - return options; - }; - }); - }) - - it('should apply plugin', function(done) { - var memory = new DataSource({connector: Memory}); - var Item = memory.createModel('Item', { name: 'string' }, { - plugins: { timestamps: true } - }); - - Item.plugin('example', { foo: 'bar' }); - - var def = memory.getModelDefinition('Item'); - var json = def.toJSON(); - var properties = json.properties; - properties.createdAt.should.eql({ type: 'Date' }); - properties.updatedAt.should.eql({ type: 'Date' }); - - Item.create({ name: 'Item 1' }, function(err, inst) { - inst.createdAt.should.be.a.date; - inst.updatedAt.should.be.a.date; - inst.example().should.eql({ foo: 'bar' }); - done(); - }); - }); - -});