diff --git a/index.js b/index.js index a1e5420e..de88d0ab 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,4 @@ +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'); 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/mixins/time-stamp.js b/lib/mixins/time-stamp.js new file mode 100644 index 00000000..b6ee77d9 --- /dev/null +++ b/lib/mixins/time-stamp.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; + }; + +}; diff --git a/lib/model-builder.js b/lib/model-builder.js index aa052dea..e46ce3a6 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 mixins = require('./mixins'); // Set up types require('./types')(ModelBuilder); @@ -428,6 +429,22 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett ModelClass.registerProperty(propertyName); } + var mixinSettings = settings.mixins || {}; + var keys = Object.keys(mixinSettings); + var size = keys.length; + for (i = 0; i < size; i++) { + var name = keys[i]; + var mixin = mixinSettings[name]; + if (mixin === true) mixin = {}; + if (typeof mixin === 'object') { + mixinSettings[name] = true; + mixins.apply(ModelClass, name, mixin); + } else { + // for settings metadata + mixinSettings[name] = false; + } + } + ModelClass.emit('defined', ModelClass); return ModelClass; diff --git a/lib/model.js b/lib/model.js index 51a59397..678226d3 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 mixins = require('./mixins'); // Set up an object for quick lookup var BASE_TYPES = { @@ -387,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 () { 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(); + }); + }); + +});