Merge pull request #403 from strongloop/feature/intent-hooks

Intent-based hooks for persistent models
This commit is contained in:
Miroslav Bajtoš 2015-01-29 08:59:28 +01:00
commit ce39f8ab01
9 changed files with 1774 additions and 210 deletions

View File

@ -594,6 +594,9 @@ Memory.prototype.updateAttributes = function updateAttributes(model, id, data, c
}
}
// Do not modify the data object passed in arguments
data = Object.create(data);
this.setIdValue(model, data, id);
var cachedModels = this.collection(model);

View File

@ -7,6 +7,7 @@ module.exports = DataAccessObject;
/*!
* Module dependencies
*/
var async = require('async');
var jutil = require('./jutil');
var ValidationError = require('./validations').ValidationError;
var Relation = require('./relations.js');
@ -62,6 +63,16 @@ function byIdQuery(m, id) {
return query;
}
function isWhereByGivenId(Model, where, idValue) {
var keys = Object.keys(where);
if (keys.length != 1) return false;
var pk = idName(Model);
if (keys[0] !== pk) return false;
return where[pk] === idValue;
}
DataAccessObject._forDB = function (data) {
if (!(this.getDataSource().isRelational && this.getDataSource().isRelational())) {
return data;
@ -201,6 +212,9 @@ DataAccessObject.create = function (data, callback) {
Model = this.lookupModel(data); // data-specific
if (Model !== obj.constructor) obj = new Model(data);
Model.notifyObserversOf('before save', { Model: Model, instance: obj }, function(err) {
if (err) return callback(err);
data = obj.toObject(true);
// validation required
@ -211,11 +225,11 @@ DataAccessObject.create = function (data, callback) {
callback(new ValidationError(obj), obj);
}
}, data);
});
function create() {
obj.trigger('create', function (createDone) {
obj.trigger('save', function (saveDone) {
var _idName = idName(Model);
var modelName = Model.modelName;
this._adapter().create(modelName, this.constructor._forDB(obj.toObject(true)), function (err, id, rev) {
@ -232,10 +246,15 @@ DataAccessObject.create = function (data, callback) {
obj.__persisted = true;
saveDone.call(obj, function () {
createDone.call(obj, function () {
if (err) {
return callback(err, obj);
}
Model.notifyObserversOf('after save', { Model: Model, instance: obj }, function(err) {
callback(err, obj);
if(!err) Model.emit('changed', obj);
});
});
});
}, obj);
}, obj, callback);
}, obj, callback);
@ -267,20 +286,42 @@ DataAccessObject.updateOrCreate = DataAccessObject.upsert = function upsert(data
}
var self = this;
var Model = this;
if (!getIdValue(this, data)) {
var id = getIdValue(this, data);
if (!id) {
return this.create(data, callback);
}
if (this.getDataSource().connector.updateOrCreate) {
Model.notifyObserversOf('query', { Model: Model, query: byIdQuery(Model, id) }, doUpdateOrCreate);
function doUpdateOrCreate(err, ctx) {
if (err) return callback(err);
var isOriginalQuery = isWhereByGivenId(Model, ctx.query.where, id)
if (Model.getDataSource().connector.updateOrCreate && isOriginalQuery) {
var context = { Model: Model, where: ctx.query.where, data: data };
Model.notifyObserversOf('before save', context, function(err, ctx) {
if (err) return callback(err);
data = ctx.data;
var update = data;
var inst = data;
if(!(data instanceof Model)) {
inst = new Model(data);
}
update = inst.toObject(false);
this.applyProperties(update, inst);
Model.applyProperties(update, inst);
Model = Model.lookupModel(update);
// FIXME(bajtos) validate the model!
// https://github.com/strongloop/loopback-datasource-juggler/issues/262
update = inst.toObject(true);
update = removeUndefined(update);
Model = this.lookupModel(update);
this.getDataSource().connector.updateOrCreate(Model.modelName, update, function (err, data) {
self.getDataSource().connector
.updateOrCreate(Model.modelName, update, done);
function done(err, data) {
var obj;
if (data && !(data instanceof Model)) {
inst._initProperties(data);
@ -288,16 +329,31 @@ DataAccessObject.updateOrCreate = DataAccessObject.upsert = function upsert(data
} else {
obj = data;
}
if (err) {
callback(err, obj);
if(!err) {
Model.emit('changed', inst);
}
} else {
Model.notifyObserversOf('after save', { Model: Model, instance: obj }, function(err) {
callback(err, obj);
if(!err) {
Model.emit('changed', inst);
}
});
}
}
});
} else {
this.findById(getIdValue(this, data), function (err, inst) {
Model.findOne({ where: ctx.query.where }, { notify: false }, function (err, inst) {
if (err) {
return callback(err);
}
if (!isOriginalQuery) {
// The custom query returned from a hook may hide the fact that
// there is already a model with `id` value `data[idName(Model)]`
delete data[idName(Model)];
}
if (inst) {
inst.updateAttributes(data, callback);
} else {
@ -307,6 +363,7 @@ DataAccessObject.updateOrCreate = DataAccessObject.upsert = function upsert(data
}
});
}
}
};
/**
@ -332,11 +389,11 @@ DataAccessObject.findOrCreate = function findOrCreate(query, data, callback) {
};
}
var t = this;
this.findOne(query, function (err, record) {
var Model = this;
Model.findOne(query, function (err, record) {
if (err) return callback(err);
if (record) return callback(null, record, false);
t.create(data, function (err, record) {
Model.create(data, function (err, record) {
callback(err, record, record != null);
});
});
@ -731,13 +788,22 @@ DataAccessObject._coerce = function (where) {
* @param {Function} callback Required callback function. Call this function with two arguments: `err` (null or Error) and an array of instances.
*/
DataAccessObject.find = function find(query, cb) {
DataAccessObject.find = function find(query, options, cb) {
if (stillConnecting(this.getDataSource(), this, arguments)) return;
if (arguments.length === 1) {
cb = query;
query = null;
options = {};
}
if (cb === undefined && typeof options === 'function') {
cb = options;
options = {};
}
if (!options) options = {};
var self = this;
query = query || {};
@ -763,7 +829,11 @@ DataAccessObject.find = function find(query, cb) {
// do in memory query
// using all documents
// TODO [fabien] use default scope here?
this.getDataSource().connector.all(this.modelName, {}, function (err, data) {
self.notifyObserversOf('query', { Model: self, query: query }, function(err, ctx) {
if (err) return cb(err);
self.getDataSource().connector.all(self.modelName, {}, function (err, data) {
var memory = new Memory();
var modelName = self.modelName;
@ -782,18 +852,20 @@ DataAccessObject.find = function find(query, cb) {
});
});
memory.all(modelName, query, cb);
// FIXME: apply "includes" and other transforms - see allCb below
memory.all(modelName, ctx.query, cb);
} else {
cb(null, []);
}
}.bind(this));
});
});
// already handled
return;
}
}
this.getDataSource().connector.all(this.modelName, query, function (err, data) {
var allCb = function (err, data) {
if (data && data.forEach) {
data.forEach(function (d, i) {
var Model = self.lookupModel(d);
@ -840,7 +912,18 @@ DataAccessObject.find = function find(query, cb) {
}
else
cb(err, []);
}
var self = this;
if (options.notify === false) {
self.getDataSource().connector.all(self.modelName, query, allCb);
} else {
this.notifyObserversOf('query', { Model: this, query: query }, function(err, ctx) {
if (err) return cb(err);
var query = ctx.query;
self.getDataSource().connector.all(self.modelName, query, allCb);
});
}
};
/**
@ -850,16 +933,22 @@ DataAccessObject.find = function find(query, cb) {
* For example: `{where: {test: 'me'}}`.
* @param {Function} cb Callback function called with (err, instance)
*/
DataAccessObject.findOne = function findOne(query, cb) {
DataAccessObject.findOne = function findOne(query, options, cb) {
if (stillConnecting(this.getDataSource(), this, arguments)) return;
if (typeof query === 'function') {
cb = query;
query = {};
}
if (cb === undefined && typeof options === 'function') {
cb = options;
options = {};
}
query = query || {};
query.limit = 1;
this.find(query, function (err, collection) {
this.find(query, options, function (err, collection) {
if (err || !collection || !collection.length > 0) return cb(err, null);
cb(err, collection[0]);
});
@ -878,41 +967,79 @@ DataAccessObject.findOne = function findOne(query, cb) {
* @param {Object} [where] Optional object that defines the criteria. This is a "where" object. Do NOT pass a filter object.
* @param {Function} [cb] Callback called with (err)
*/
DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyAll = function destroyAll(where, cb) {
DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyAll = function destroyAll(where, options, cb) {
if (stillConnecting(this.getDataSource(), this, arguments)) return;
var Model = this;
if (!cb && 'function' === typeof where) {
if (!cb && !options && 'function' === typeof where) {
cb = where;
where = undefined;
}
if (!cb && typeof options === 'function') {
cb = options;
}
if (!cb) cb = function(){};
if (!options) options = {};
var query = { where: where };
this.applyScope(query);
where = query.where;
if (!where || (typeof where === 'object' && Object.keys(where).length === 0)) {
this.getDataSource().connector.destroyAll(this.modelName, function (err, data) {
cb && cb(err, data);
if(!err) Model.emit('deletedAll');
}.bind(this));
var context = { Model: Model, where: whereIsEmpty(where) ? {} : where };
if (options.notify === false) {
doDelete(where);
} else {
query = { where: whereIsEmpty(where) ? {} : where };
Model.notifyObserversOf('query',
{ Model: Model, query: query },
function(err, ctx) {
if (err) return cb(err);
doDelete(ctx.query.where);
});
}
function doDelete(where) {
if (whereIsEmpty(where)) {
Model.getDataSource().connector.destroyAll(Model.modelName, done);
} else {
try {
// Support an optional where object
where = removeUndefined(where);
where = this._coerce(where);
where = Model._coerce(where);
} catch (err) {
return process.nextTick(function() {
cb && cb(err);
});
}
this.getDataSource().connector.destroyAll(this.modelName, where, function (err, data) {
cb && cb(err, data);
if(!err) Model.emit('deletedAll', where);
}.bind(this));
Model.getDataSource().connector.destroyAll(Model.modelName, where, done);
}
function done(err, data) {
if (err) return cb(er);
if (options.notify === false) {
return cb(err, data);
}
Model.notifyObserversOf('after delete', { Model: Model, where: where }, function(err) {
cb(err, data);
if (!err)
Model.emit('deletedAll', whereIsEmpty(where) ? undefined : where);
});
}
}
};
function whereIsEmpty(where) {
return !where ||
(typeof where === 'object' && Object.keys(where).length === 0)
}
/**
* Delete the record with the specified ID.
* Aliases are `destroyById` and `deleteById`.
@ -969,7 +1096,12 @@ DataAccessObject.count = function (where, cb) {
});
}
this.getDataSource().connector.count(this.modelName, cb, where);
var Model = this;
this.notifyObserversOf('query', { Model: Model, query: { where: where } }, function(err, ctx) {
if (err) return cb(err);
where = ctx.query.where;
Model.getDataSource().connector.count(Model.modelName, cb, where);
});
};
/**
@ -1012,6 +1144,10 @@ DataAccessObject.prototype.save = function (options, callback) {
inst.setAttributes(data);
}
Model.notifyObserversOf('before save', { Model: Model, instance: inst }, function(err) {
if (err) return callback(err);
data = inst.toObject(true);
// validate first
if (!options.validate) {
return save();
@ -1040,6 +1176,8 @@ DataAccessObject.prototype.save = function (options, callback) {
return callback(err, inst);
}
inst._initProperties(data, { persisted: true });
Model.notifyObserversOf('after save', { Model: Model, instance: inst }, function(err) {
if (err) return callback(err, inst);
updateDone.call(inst, function () {
saveDone.call(inst, function () {
callback(err, inst);
@ -1049,9 +1187,11 @@ DataAccessObject.prototype.save = function (options, callback) {
});
});
});
});
}, data, callback);
}, data, callback);
}
});
};
/**
@ -1100,17 +1240,49 @@ DataAccessObject.updateAll = function (where, data, cb) {
where = query.where;
var Model = this;
Model.notifyObserversOf('query', { Model: Model, query: { where: where } }, function(err, ctx) {
if (err) return cb && cb(err);
Model.notifyObserversOf(
'before save',
{
Model: Model,
where: ctx.query.where,
data: data
},
function(err, ctx) {
if (err) return cb && cb(err);
doUpdate(ctx.where, ctx.data);
});
});
function doUpdate(where, data) {
try {
where = removeUndefined(where);
where = this._coerce(where);
where = Model._coerce(where);
} catch (err) {
return process.nextTick(function () {
cb && cb(err);
});
}
var connector = this.getDataSource().connector;
connector.update(this.modelName, where, data, cb);
var connector = Model.getDataSource().connector;
connector.update(Model.modelName, where, data, function(err, count) {
if (err) return cb && cb (err);
Model.notifyObserversOf(
'after save',
{
Model: Model,
where: where,
data: data
},
function(err, ctx) {
return cb && cb(err, count);
});
});
}
};
DataAccessObject.prototype.isNewRecord = function () {
@ -1134,21 +1306,48 @@ DataAccessObject.prototype.remove =
DataAccessObject.prototype.delete =
DataAccessObject.prototype.destroy = function (cb) {
if (stillConnecting(this.getDataSource(), this, arguments)) return;
var self = this;
var Model = this.constructor;
var id = getIdValue(this.constructor, this);
this.trigger('destroy', function (destroyed) {
this._adapter().destroy(this.constructor.modelName, id, function (err) {
Model.notifyObserversOf(
'query',
{ Model: Model, query: byIdQuery(Model, id) },
function(err, ctx) {
if (err) return cb(err);
doDeleteInstance(ctx.query.where);
});
function doDeleteInstance(where) {
if (!isWhereByGivenId(Model, where, id)) {
// A hook modified the query, it is no longer
// a simple 'delete model with the given id'.
// We must switch to full query-based delete.
Model.deleteAll(where, { notify: false }, function(err) {
if (err) return cb && cb(err);
Model.notifyObserversOf('after delete', { Model: Model, where: where }, function(err) {
cb && cb(err);
if (!err) Model.emit('deleted', id);
});
});
return;
}
self.trigger('destroy', function (destroyed) {
self._adapter().destroy(self.constructor.modelName, id, function (err) {
if (err) {
return cb(err);
}
destroyed(function () {
if (cb) cb();
Model.emit('deleted', id);
Model.notifyObserversOf('after delete', { Model: Model, where: where }, function(err) {
cb && cb(err);
if (!err) Model.emit('deleted', id);
});
});
});
}.bind(this));
}, null, cb);
}
};
/**
@ -1231,15 +1430,29 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb
data = {};
}
if (!cb) {
cb = function() {};
}
var context = {
Model: Model,
where: byIdQuery(Model, getIdValue(Model, inst)).where,
data: data
};
Model.notifyObserversOf('before save', context, function(err, ctx) {
if (err) return cb(err);
data = ctx.data;
// update instance's properties
inst.setAttributes(data);
inst.isValid(function (valid) {
if (!valid) {
if (cb) {
cb(new ValidationError(inst), inst);
return;
}
} else {
inst.trigger('save', function (saveDone) {
inst.trigger('update', function (done) {
var typedData = {};
@ -1260,15 +1473,18 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb
if (!err) inst.__persisted = true;
done.call(inst, function () {
saveDone.call(inst, function () {
if(cb) cb(err, inst);
if (err) return cb(err, inst);
Model.notifyObserversOf('after save', { Model: Model, instance: inst }, function(err) {
if(!err) Model.emit('changed', inst);
cb(err, inst);
});
});
});
});
}, data, cb);
}, data, cb);
}
}, data);
});
};
/**

View File

@ -199,6 +199,7 @@ ModelBuilder.prototype.define = function defineClass(className, properties, sett
hiddenProperty(ModelClass, 'relations', {});
hiddenProperty(ModelClass, 'http', { path: '/' + pathName });
hiddenProperty(ModelClass, 'base', ModelBaseClass);
hiddenProperty(ModelClass, '_observers', {});
// inherit ModelBaseClass static methods
for (var i in ModelBaseClass) {

View File

@ -7,6 +7,7 @@ module.exports = ModelBaseClass;
* Module dependencies
*/
var async = require('async');
var util = require('util');
var jutil = require('./jutil');
var List = require('./list');
@ -503,5 +504,52 @@ ModelBaseClass.prototype.setStrict = function (strict) {
this.__strict = strict;
};
/**
* Register an asynchronous observer for the given operation (event).
* @param {String} operation The operation name.
* @callback {function} listener The listener function. It will be invoked with
* `this` set to the model constructor, e.g. `User`.
* @param {Object} context Operation-specific context.
* @param {function(Error=)} next The callback to call when the observer
* has finished.
* @end
*/
ModelBaseClass.observe = function(operation, listener) {
if (!this._observers[operation]) {
this._observers[operation] = [];
}
this._observers[operation].push(listener);
};
/**
* Invoke all async observers for the given operation.
* @param {String} operation The operation name.
* @param {Object} context Operation-specific context.
* @param {function(Error=)} callback The callback to call when all observers
* has finished.
*/
ModelBaseClass.notifyObserversOf = function(operation, context, callback) {
var observers = this._observers && this._observers[operation];
this._notifyBaseObservers(operation, context, function doNotify(err) {
if (err) return callback(err, context);
if (!observers || !observers.length) return callback(null, context);
async.eachSeries(
observers,
function(fn, next) { fn(context, next); },
function(err) { callback(err, context) }
);
});
}
ModelBaseClass._notifyBaseObservers = function(operation, context, callback) {
if (this.base && this.base.notifyObserversOf)
this.base.notifyObserversOf(operation, context, callback);
else
callback();
}
jutil.mixin(ModelBaseClass, Hookable);
jutil.mixin(ModelBaseClass, validations.Validatable);

View File

@ -23,8 +23,8 @@
"node >= 0.6"
],
"devDependencies": {
"should": "~1.2.2",
"mocha": "~1.20.1"
"mocha": "~1.20.1",
"should": "^1.3.0"
},
"dependencies": {
"async": "~0.9.0",

103
test/async-observer.test.js Normal file
View File

@ -0,0 +1,103 @@
var ModelBuilder = require('../').ModelBuilder;
var should = require('./init');
describe('async observer', function() {
var TestModel;
beforeEach(function defineTestModel() {
var modelBuilder = new ModelBuilder();
TestModel = modelBuilder.define('TestModel', { name: String });
});
it('calls registered async observers', function(done) {
var notifications = [];
TestModel.observe('before', pushAndNext(notifications, 'before'));
TestModel.observe('after', pushAndNext(notifications, 'after'));
TestModel.notifyObserversOf('before', {}, function(err) {
if (err) return done(err);
notifications.push('call');
TestModel.notifyObserversOf('after', {}, function(err) {
if (err) return done(err);
notifications.should.eql(['before', 'call', 'after']);
done();
});
});
});
it('allows multiple observers for the same operation', function(done) {
var notifications = [];
TestModel.observe('event', pushAndNext(notifications, 'one'));
TestModel.observe('event', pushAndNext(notifications, 'two'));
TestModel.notifyObserversOf('event', {}, function(err) {
if (err) return done(err);
notifications.should.eql(['one', 'two']);
done();
});
});
it('inherits observers from base model', function(done) {
var notifications = [];
TestModel.observe('event', pushAndNext(notifications, 'base'));
var Child = TestModel.extend('Child');
Child.observe('event', pushAndNext(notifications, 'child'));
Child.notifyObserversOf('event', {}, function(err) {
if (err) return done(err);
notifications.should.eql(['base', 'child']);
done();
});
});
it('does not modify observers in the base model', function(done) {
var notifications = [];
TestModel.observe('event', pushAndNext(notifications, 'base'));
var Child = TestModel.extend('Child');
Child.observe('event', pushAndNext(notifications, 'child'));
TestModel.notifyObserversOf('event', {}, function(err) {
if (err) return done(err);
notifications.should.eql(['base']);
done();
});
});
it('always calls inherited observers', function(done) {
var notifications = [];
TestModel.observe('event', pushAndNext(notifications, 'base'));
var Child = TestModel.extend('Child');
// Important: there are no observers on the Child model
Child.notifyObserversOf('event', {}, function(err) {
if (err) return done(err);
notifications.should.eql(['base']);
done();
});
});
it('handles no observers', function(done) {
TestModel.notifyObserversOf('no-observers', {}, function(err) {
// the test passes when no error was raised
done(err);
});
});
it('passes context to final callback', function(done) {
var context = {};
TestModel.notifyObserversOf('event', context, function(err, ctx) {
(ctx || "null").should.equal(context);
done();
});
});
});
function pushAndNext(array, value) {
return function(ctx, next) {
array.push(value);
process.nextTick(next);
};
}

View File

@ -445,7 +445,7 @@ function addHooks(name, done) {
};
User['after' + name] = function (next) {
(new Boolean(called)).should.equal(true);
this.email.should.equal(random);
this.should.have.property('email', random);
done();
};
}

View File

@ -5,6 +5,7 @@ var fs = require('fs');
var assert = require('assert');
var async = require('async');
var should = require('./init.js');
var Memory = require('../lib/connectors/memory').Memory;
describe('Memory connector', function () {
var file = path.join(__dirname, 'memory.json');
@ -359,6 +360,17 @@ describe('Memory connector', function () {
});
});
require('./persistence-hooks.suite')(
new DataSource({ connector: Memory }),
should);
});
describe('Unoptimized connector', function() {
var ds = new DataSource({ connector: Memory });
// disable optimized methods
ds.connector.updateOrCreate = false;
require('./persistence-hooks.suite')(ds, should);
});

File diff suppressed because it is too large Load Diff