From 1fd6eff10fae7e5afd5d4499d97e177d5857fdc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Wed, 21 Jan 2015 17:57:47 +0100 Subject: [PATCH] Intent-based hooks for persistence This patch introduces a new API for "intent-based" hooks. These hooks are not tied to a particular method (e.g. "find" or "update"). Instead, they are triggered from all methods that execute a particular "intent". The consumer API is very simple, there is a new method Model.observe(name, observer), where the observer is function observer(context, callback). Observers are inherited by child models and it is possible to register multiple observers for the same hook. List of hooks: - query - before save - after save - after delete --- lib/connectors/memory.js | 3 + lib/dao.js | 598 +++++++++++----- test/hooks.test.js | 2 +- test/memory.test.js | 24 +- test/persistence-hooks.suite.js | 1181 +++++++++++++++++++++++++++++++ 5 files changed, 1610 insertions(+), 198 deletions(-) create mode 100644 test/persistence-hooks.suite.js diff --git a/lib/connectors/memory.js b/lib/connectors/memory.js index 7c80930c..efadb665 100644 --- a/lib/connectors/memory.js +++ b/lib/connectors/memory.js @@ -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); diff --git a/lib/dao.js b/lib/dao.js index a4d12867..4e868279 100644 --- a/lib/dao.js +++ b/lib/dao.js @@ -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; @@ -133,7 +144,7 @@ DataAccessObject.create = function (data, callback) { var Model = this; var self = this; - + if (typeof data === 'function') { callback = data; data = {}; @@ -183,39 +194,42 @@ DataAccessObject.create = function (data, callback) { } } } - + var enforced = {}; var obj; var idValue = getIdValue(this, data); - + // if we come from save if (data instanceof Model && !idValue) { obj = data; } else { obj = new Model(data); } - + this.applyProperties(enforced, obj); obj.setAttributes(enforced); - + Model = this.lookupModel(data); // data-specific if (Model !== obj.constructor) obj = new Model(data); - - data = obj.toObject(true); - - // validation required - obj.isValid(function (valid) { - if (valid) { - create(); - } else { - callback(new ValidationError(obj), obj); - } - }, data); - + + Model.notifyObserversOf('before save', { Model: Model, instance: obj }, function(err) { + if (err) return callback(err); + + data = obj.toObject(true); + + // validation required + obj.isValid(function (valid) { + if (valid) { + create(); + } else { + 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,8 +246,13 @@ DataAccessObject.create = function (data, callback) { obj.__persisted = true; saveDone.call(obj, function () { createDone.call(obj, function () { - callback(err, obj); - if(!err) Model.emit('changed', obj); + 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); @@ -267,45 +286,83 @@ 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) { - var update = data; - var inst = data; - if(!(data instanceof Model)) { - inst = new Model(data); + + 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); + + 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); + self.getDataSource().connector + .updateOrCreate(Model.modelName, update, done); + + function done(err, data) { + var obj; + if (data && !(data instanceof Model)) { + inst._initProperties(data); + obj = inst; + } 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 { + 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 { + Model = self.lookupModel(data); + var obj = new Model(data); + obj.save(data, callback); + } + }); } - update = inst.toObject(false); - this.applyProperties(update, inst); - update = removeUndefined(update); - Model = this.lookupModel(update); - this.getDataSource().connector.updateOrCreate(Model.modelName, update, function (err, data) { - var obj; - if (data && !(data instanceof Model)) { - inst._initProperties(data); - obj = inst; - } else { - obj = data; - } - callback(err, obj); - if(!err) { - Model.emit('changed', inst); - } - }); - } else { - this.findById(getIdValue(this, data), function (err, inst) { - if (err) { - return callback(err); - } - if (inst) { - inst.updateAttributes(data, callback); - } else { - Model = self.lookupModel(data); - var obj = new Model(data); - obj.save(data, callback); - } - }); } }; @@ -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); }); }); @@ -383,13 +440,13 @@ DataAccessObject.findByIds = function(ids, cond, cb) { cb = cond; cond = {}; } - + var pk = idName(this); if (ids.length === 0) { process.nextTick(function() { cb(null, []); }); return; } - + var filter = { where: {} }; filter.where[pk] = { inq: [].concat(ids) }; mergeQuery(filter, cond || {}); @@ -731,17 +788,26 @@ 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 || {}; - + try { this._normalize(query); } catch (err) { @@ -751,7 +817,7 @@ DataAccessObject.find = function find(query, cb) { } this.applyScope(query); - + var near = query && geo.nearFilter(query.where); var supportsGeo = !!this.getDataSource().connector.buildNearFilter; @@ -763,42 +829,48 @@ 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) { - var memory = new Memory(); - var modelName = self.modelName; - if (err) { - cb(err); - } else if (Array.isArray(data)) { - memory.define({ - properties: self.dataSource.definitions[self.modelName].properties, - settings: self.dataSource.definitions[self.modelName].settings, - model: self - }); + self.notifyObserversOf('query', { Model: self, query: query }, function(err, ctx) { + if (err) return cb(err); - data.forEach(function (obj) { - memory.create(modelName, obj, function () { - // noop + self.getDataSource().connector.all(self.modelName, {}, function (err, data) { + var memory = new Memory(); + var modelName = self.modelName; + + if (err) { + cb(err); + } else if (Array.isArray(data)) { + memory.define({ + properties: self.dataSource.definitions[self.modelName].properties, + settings: self.dataSource.definitions[self.modelName].settings, + model: self }); - }); - memory.all(modelName, query, cb); - } else { - cb(null, []); - } - }.bind(this)); + data.forEach(function (obj) { + memory.create(modelName, obj, function () { + // noop + }); + }); + + // FIXME: apply "includes" and other transforms - see allCb below + memory.all(modelName, ctx.query, cb); + } else { + cb(null, []); + } + }); + }); // 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); var obj = new Model(d, {fields: query.fields, applySetters: false, persisted: true}); - + if (query && query.include) { if (query.collect) { // The collect property indicates that the query is to return the @@ -815,7 +887,7 @@ DataAccessObject.find = function find(query, cb) { if (utils.isPlainObject(inc)) { relationName = Object.keys(inc)[0]; } - + // Promote the included model as a direct property var data = obj.__cachedRelations[relationName]; if(Array.isArray(data)) { @@ -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 { - try { - // Support an optional where object - where = removeUndefined(where); - where = this._coerce(where); - } catch (err) { - return process.nextTick(function() { - cb && cb(err); + 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 = Model._coerce(where); + } catch (err) { + return process.nextTick(function() { + cb && cb(err); + }); + } + + 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); }); } - this.getDataSource().connector.destroyAll(this.modelName, where, function (err, data) { - cb && cb(err, data); - if(!err) Model.emit('deletedAll', where); - }.bind(this)); } }; +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`. @@ -926,7 +1053,7 @@ DataAccessObject.remove = DataAccessObject.deleteAll = DataAccessObject.destroyA DataAccessObject.removeById = DataAccessObject.destroyById = DataAccessObject.deleteById = function deleteById(id, cb) { if (stillConnecting(this.getDataSource(), this, arguments)) return; var Model = this; - + this.remove(byIdQuery(this, id).where, function(err) { if ('function' === typeof cb) { cb(err); @@ -955,11 +1082,11 @@ DataAccessObject.count = function (where, cb) { cb = where; where = null; } - + var query = { where: where }; this.applyScope(query); where = query.where; - + try { where = removeUndefined(where); where = this._coerce(where); @@ -968,8 +1095,13 @@ DataAccessObject.count = function (where, cb) { cb && cb(err); }); } - - 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); + }); }; /** @@ -999,59 +1131,67 @@ DataAccessObject.prototype.save = function (options, callback) { if (!('throws' in options)) { options.throws = false; } - + var inst = this; var data = inst.toObject(true); var modelName = Model.modelName; - + Model.applyProperties(data, this); - + if (this.isNewRecord()) { return Model.create(this, callback); } else { inst.setAttributes(data); } - // validate first - if (!options.validate) { - return save(); - } + Model.notifyObserversOf('before save', { Model: Model, instance: inst }, function(err) { + if (err) return callback(err); + data = inst.toObject(true); - inst.isValid(function (valid) { - if (valid) { - save(); - } else { - var err = new ValidationError(inst); - // throws option is dangerous for async usage - if (options.throws) { - throw err; - } - callback(err, inst); + // validate first + if (!options.validate) { + return save(); } - }); - // then save - function save() { - inst.trigger('save', function (saveDone) { - inst.trigger('update', function (updateDone) { - data = removeUndefined(data); - inst._adapter().save(modelName, inst.constructor._forDB(data), function (err) { - if (err) { - return callback(err, inst); - } - inst._initProperties(data, { persisted: true }); - updateDone.call(inst, function () { - saveDone.call(inst, function () { - callback(err, inst); - if(!err) { - Model.emit('changed', inst); - } + inst.isValid(function (valid) { + if (valid) { + save(); + } else { + var err = new ValidationError(inst); + // throws option is dangerous for async usage + if (options.throws) { + throw err; + } + callback(err, inst); + } + }); + + // then save + function save() { + inst.trigger('save', function (saveDone) { + inst.trigger('update', function (updateDone) { + data = removeUndefined(data); + inst._adapter().save(modelName, inst.constructor._forDB(data), function (err) { + if (err) { + 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); + if(!err) { + Model.emit('changed', inst); + } + }); + }); }); }); - }); + }, data, callback); }, data, callback); - }, data, callback); - } + } + }); }; /** @@ -1093,24 +1233,56 @@ DataAccessObject.updateAll = function (where, data, cb) { assert(typeof where === 'object', 'The where argument should be an object'); assert(typeof data === 'object', 'The data argument should be an object'); assert(cb === null || typeof cb === 'function', 'The cb argument should be a function'); - + var query = { where: where }; this.applyScope(query); this.applyProperties(data); - + where = query.where; - - try { - where = removeUndefined(where); - where = this._coerce(where); - } catch (err) { - return process.nextTick(function () { - cb && cb(err); + + 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 = Model._coerce(where); + } catch (err) { + return process.nextTick(function () { + cb && cb(err); + }); + } + + 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); + }); }); } - - var connector = this.getDataSource().connector; - connector.update(this.modelName, where, data, cb); }; DataAccessObject.prototype.isNewRecord = function () { @@ -1134,23 +1306,50 @@ 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) { - if (err) { - return cb(err); - } + Model.notifyObserversOf( + 'query', + { Model: Model, query: byIdQuery(Model, id) }, + function(err, ctx) { + if (err) return cb(err); + doDeleteInstance(ctx.query.where); + }); - destroyed(function () { - if (cb) cb(); - Model.emit('deleted', id); + 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); + }); }); - }.bind(this)); - }, null, cb); + return; + } + + self.trigger('destroy', function (destroyed) { + self._adapter().destroy(self.constructor.modelName, id, function (err) { + if (err) { + return cb(err); + } + + destroyed(function () { + Model.notifyObserversOf('after delete', { Model: Model, where: where }, function(err) { + cb && cb(err); + if (!err) Model.emit('deleted', id); + }); + }); + }); + }, null, cb); + } }; - + /** * Set a single attribute. * Equivalent to `setAttributes({name: value})` @@ -1160,7 +1359,7 @@ DataAccessObject.prototype.remove = */ DataAccessObject.prototype.setAttribute = function setAttribute(name, value) { this[name] = value; // TODO [fabien] - currently not protected by applyProperties -}; +}; /** * Update a single attribute. @@ -1184,17 +1383,17 @@ DataAccessObject.prototype.updateAttribute = function updateAttribute(name, valu */ DataAccessObject.prototype.setAttributes = function setAttributes(data) { if (typeof data !== 'object') return; - + this.constructor.applyProperties(data, this); - + var Model = this.constructor; var inst = this; - + // update instance's properties for (var key in data) { inst.setAttribute(key, data[key]); } - + Model.emit('set', inst); }; @@ -1231,15 +1430,29 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb data = {}; } - // update instance's properties - inst.setAttributes(data); + if (!cb) { + cb = function() {}; + } - inst.isValid(function (valid) { - if (!valid) { - if (cb) { + 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) { cb(new ValidationError(inst), inst); + return; } - } else { + inst.trigger('save', function (saveDone) { inst.trigger('update', function (done) { var typedData = {}; @@ -1248,7 +1461,7 @@ DataAccessObject.prototype.updateAttributes = function updateAttributes(data, cb // Convert the properties by type inst[key] = data[key]; typedData[key] = inst[key]; - if (typeof typedData[key] === 'object' + if (typeof typedData[key] === 'object' && typedData[key] !== null && typeof typedData[key].toObject === 'function') { typedData[key] = typedData[key].toObject(); @@ -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) Model.emit('changed', 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); + }, data); + }); }; /** diff --git a/test/hooks.test.js b/test/hooks.test.js index b1502f99..319a2fec 100644 --- a/test/hooks.test.js +++ b/test/hooks.test.js @@ -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(); }; } diff --git a/test/memory.test.js b/test/memory.test.js index 6b1a608e..65e1ec9f 100644 --- a/test/memory.test.js +++ b/test/memory.test.js @@ -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'); @@ -278,27 +279,27 @@ describe('Memory connector', function () { } }); - + it('should use collection setting', function (done) { var ds = new DataSource({ connector: 'memory' }); - + var Product = ds.createModel('Product', { name: String }); - + var Tool = ds.createModel('Tool', { name: String }, {memory: {collection: 'Product'}}); - + var Widget = ds.createModel('Widget', { name: String }, {memory: {collection: 'Product'}}); - + ds.connector.getCollection('Tool').should.equal('Product'); ds.connector.getCollection('Widget').should.equal('Product'); - + async.series([ function(next) { Tool.create({ name: 'Tool A' }, next); @@ -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); }); diff --git a/test/persistence-hooks.suite.js b/test/persistence-hooks.suite.js new file mode 100644 index 00000000..6d4865e8 --- /dev/null +++ b/test/persistence-hooks.suite.js @@ -0,0 +1,1181 @@ +var ValidationError = require('../').ValidationError; +var traverse = require('traverse'); + +module.exports = function(dataSource, should) { + describe('Persistence hooks', function() { + var observedContexts, expectedError, observersCalled; + var TestModel, existingInstance; + var migrated = false, lastId; + + beforeEach(function setupDatabase(done) { + observedContexts = "hook not called"; + expectedError = new Error('test error'); + observersCalled = []; + + TestModel = dataSource.createModel('TestModel', { + id: { type: String, id: true, default: uid }, + name: { type: String, required: true }, + extra: { type: String, required: false } + }); + + lastId = 0; + + if (migrated) { + TestModel.deleteAll(done); + } else { + dataSource.automigrate(TestModel.modelName, function(err) { + migrated = true; + done(err); + }); + } + }); + + beforeEach(function createTestData(done) { + TestModel.create({ name: 'first' }, function(err, instance) { + if (err) return done(err); + existingInstance = instance; + + TestModel.create({ name: 'second' }, function(err) { + if (err) return done(err); + done(); + }); + }); + }); + + describe('PersistedModel.find', function() { + it('triggers `query` hook', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.find({ where: { id: '1' } }, function(err, list) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + query: { where: { id: '1' } } + })); + done(); + }); + }); + + it('aborts when `query` hook fails', function(done) { + TestModel.observe('query', nextWithError(expectedError)); + + TestModel.find(function(err, list) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `query` hook', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: existingInstance.id } }; + next(); + }); + + TestModel.find(function(err, list) { + if (err) return done(err); + list.map(get('name')).should.eql([existingInstance.name]); + done(); + }); + }); + + it('triggers `query` hook for geo queries', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.find({ where: { geo: { near: '10,20' }}}, function(err, list) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + query: { where: { geo: { near: '10,20' } } } + })); + done(); + }); + }); + + it('applies updates from `query` hook for geo queries', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: existingInstance.id } }; + next(); + }); + + TestModel.find({ where: { geo: { near: '10,20' } } }, function(err, list) { + if (err) return done(err); + list.map(get('name')).should.eql([existingInstance.name]); + done(); + }); + }); + }); + + describe('PersistedModel.create', function() { + it('triggers `before save` hook', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.create({ name: 'created' }, function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: instance.id, + name: 'created', + extra: undefined + }})); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + TestModel.create({ name: 'created' }, function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `before save` hook', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + TestModel.create({ id: uid(), name: 'a-name' }, function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + + it('sends `before save` for each model in an array', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.create( + [{ name: 'one' }, { name: 'two' }], + function(err, list) { + if (err) return done(err); + observedContexts.should.eql([ + aTestModelCtx({ + instance: { id: list[0].id, name: 'one', extra: undefined } + }), + aTestModelCtx({ + instance: { id: list[1].id, name: 'two', extra: undefined } + }), + ]); + done(); + }); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.create({ name: 'created' }, function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.create({ name: 'created' }, function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: instance.id, + name: 'created', + extra: undefined + }})); + done(); + }); + }); + + it('aborts when `after save` hook fails', function(done) { + TestModel.observe('after save', nextWithError(expectedError)); + + TestModel.create({ name: 'created' }, function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `after save` hook', function(done) { + TestModel.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + TestModel.create({ name: 'a-name' }, function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + + it('sends `after save` for each model in an array', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.create( + [{ name: 'one' }, { name: 'two' }], + function(err, list) { + if (err) return done(err); + observedContexts.should.eql([ + aTestModelCtx({ + instance: { id: list[0].id, name: 'one', extra: undefined } + }), + aTestModelCtx({ + instance: { id: list[1].id, name: 'two', extra: undefined } + }), + ]); + done(); + }); + }); + + it('emits `after save` when some models were not saved', function(done) { + TestModel.observe('before save', function(ctx, next) { + if (ctx.instance.name === 'fail') + next(expectedError); + else + next(); + }); + + TestModel.observe('after save', pushContextAndNext()); + + TestModel.create( + [{ name: 'ok' }, { name: 'fail' }], + function(err, list) { + (err || []).should.have.length(2); + err[1].should.eql(expectedError); + + // NOTE(bajtos) The current implementation of `Model.create(array)` + // passes all models in the second callback argument, including + // the models that were not created due to an error. + list.map(get('name')).should.eql(['ok', 'fail']); + + observedContexts.should.eql(aTestModelCtx({ + instance: { id: list[0].id, name: 'ok', extra: undefined } + })); + done(); + }); + }); + }); + + describe('PersistedModel.findOrCreate', function() { + it('triggers `query` hook', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.findOrCreate( + { where: { name: 'new-record' } }, + { name: 'new-record' }, + function(err, record, created) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { name: 'new-record' }, + limit: 1, + offset: 0, + skip: 0 + }})); + done(); + }); + }); + + // TODO(bajtos) Enable this test for all connectors that + // provide optimized implementation of findOrCreate. + // The unoptimized implementation does not trigger the hook + // when an existing model was found. + it.skip('triggers `before save` hook when found', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.findOrCreate( + { where: { name: existingInstance.name } }, + { name: existingInstance.name }, + function(err, record, created) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: record.id, + name: existingInstance.name, + extra: undefined + }})); + done(); + }); + }); + + it('triggers `before save` hook when not found', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.findOrCreate( + { where: { name: 'new-record' } }, + { name: 'new-record' }, + function(err, record, created) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: record.id, + name: 'new-record', + extra: undefined + }})); + done(); + }); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.findOrCreate( + { where: { name: 'new-record' } }, + { name: 'new-record' }, + function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers hooks in the correct order when not found', function(done) { + var triggered = []; + TestModel._notify = TestModel.notifyObserversOf; + TestModel.notifyObserversOf = function(operation, context, callback) { + triggered.push(operation); + this._notify.apply(this, arguments); + }; + + TestModel.findOrCreate( + { where: { name: 'new-record' } }, + { name: 'new-record' }, + function(err, record, created) { + if (err) return done(err); + triggered.should.eql([ + 'query', + 'before save', + 'after save' + ]); + done(); + }); + }); + + it('aborts when `query` hook fails', function(done) { + TestModel.observe('query', nextWithError(expectedError)); + + TestModel.findOrCreate( + { where: { id: 'does-not-exist' } }, + { name: 'does-not-exist' }, + function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + TestModel.findOrCreate( + { where: { id: 'does-not-exist' } }, + { name: 'does-not-exist' }, + function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('triggers `after save` hook when not found', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.findOrCreate( + { where: { name: 'new name' } }, + { name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: instance.id, + name: 'new name', + extra: undefined + }})); + done(); + }); + }); + + it('does not trigger `after save` hook when found', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.findOrCreate( + { where: { id: existingInstance.id } }, + { name: existingInstance.name }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql("hook not called"); + done(); + }); + }); + }); + + describe('PersistedModel.count', function(done) { + it('triggers `query` hook', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.count({ id: existingInstance.id }, function(err, count) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { id: existingInstance.id } + }})); + done(); + }); + }); + + it('applies updates from `query` hook', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query.where = { id: existingInstance.id }; + next(); + }); + + TestModel.count(function(err, count) { + if (err) return done(err); + count.should.equal(1); + done(); + }); + }); + }); + + describe('PersistedModel.prototype.save', function() { + it('triggers `before save` hook', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + existingInstance.name = 'changed'; + existingInstance.save(function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: existingInstance.id, + name: 'changed', + extra: undefined + }})); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + existingInstance.save(function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `before save` hook', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + existingInstance.save(function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + existingInstance.save(function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + existingInstance.name = 'changed'; + existingInstance.save(function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: existingInstance.id, + name: 'changed', + extra: undefined + }})); + done(); + }); + }); + + it('aborts when `after save` hook fails', function(done) { + TestModel.observe('after save', nextWithError(expectedError)); + + existingInstance.save(function(err, instance) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `after save` hook', function(done) { + TestModel.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + existingInstance.save(function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + }); + + describe('PersistedModel.prototype.updateAttributes', function() { + it('triggers `before save` hook', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + existingInstance.name = 'changed'; + existingInstance.updateAttributes({ name: 'changed' }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { id: existingInstance.id }, + data: { name: 'changed' } + })); + done(); + }); + }); + + it('aborts when `before save` hook fails', function(done) { + TestModel.observe('before save', nextWithError(expectedError)); + + existingInstance.updateAttributes({ name: 'updated' }, function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `before save` hook', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.data.extra = 'extra data'; + ctx.data.name = 'hooked name'; + next(); + }); + + existingInstance.updateAttributes({ name: 'updated' }, function(err) { + if (err) return done(err); + // We must query the database here because `updateAttributes` + // returns effectively `this`, not the data from the datasource + TestModel.findById(existingInstance.id, function(err, instance) { + if (err) return done(err); + instance.toObject(true).should.eql({ + id: existingInstance.id, + name: 'hooked name', + extra: 'extra data' + }); + done(); + }); + }); + }); + + it('validates model after `before save` hook', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + existingInstance.updateAttributes({ name: 'updated' }, function(err) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + existingInstance.name = 'changed'; + existingInstance.updateAttributes({ name: 'changed' }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: existingInstance.id, + name: 'changed', + extra: undefined + }})); + done(); + }); + }); + + it('aborts when `after save` hook fails', function(done) { + TestModel.observe('after save', nextWithError(expectedError)); + + existingInstance.updateAttributes({ name: 'updated' }, function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('applies updates from `after save` hook', function(done) { + TestModel.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(TestModel); + ctx.instance.extra = 'hook data'; + next(); + }); + + existingInstance.updateAttributes({ name: 'updated' }, function(err, instance) { + if (err) return done(err); + instance.should.have.property('extra', 'hook data'); + done(); + }); + }); + }); + + describe('PersistedModel.updateOrCreate', function() { + it('triggers `query` hook on create', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: 'not-found', name: 'not found' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { id: 'not-found' } + }})); + done(); + }); + }); + + it('triggers `query` hook on update', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { id: existingInstance.id } + }})); + done(); + }); + }); + + it('does not trigger `query` on missing id', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.updateOrCreate( + { name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.equal('hook not called'); + done(); + }); + }); + + it('applies updates from `query` hook when found', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + findTestModels({ fields: ['id', 'name' ] }, function(err, list) { + if (err) return done(err); + (list||[]).map(toObject).should.eql([ + { id: existingInstance.id, name: existingInstance.name, extra: undefined }, + { id: instance.id, name: 'new name', extra: undefined } + ]); + done(); + }); + }); + }); + + it('applies updates from `query` hook when not found', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: 'not-found' } }; + next(); + }); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'new name' }, + function(err, instance) { + if (err) return done(err); + findTestModels({ fields: ['id', 'name' ] }, function(err, list) { + if (err) return done(err); + (list||[]).map(toObject).should.eql([ + { id: existingInstance.id, name: existingInstance.name, extra: undefined }, + { id: list[1].id, name: 'second', extra: undefined }, + { id: instance.id, name: 'new name', extra: undefined } + ]); + done(); + }); + }); + }); + + it('triggers hooks only once', function(done) { + TestModel.observe('query', pushNameAndNext('query')); + TestModel.observe('before save', pushNameAndNext('before save')); + + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.updateOrCreate( + { id: 'ignored', name: 'new name' }, + function(err, instance) { + if (err) return done(err); + observersCalled.should.eql(['query', 'before save']); + done(); + }); + }); + + it('triggers `before save` hook on update', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { id: existingInstance.id }, + data: { id: existingInstance.id, name: 'updated name' } + })); + done(); + }); + }); + + it('triggers `before save` hook on create', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + + if (dataSource.connector.updateOrCreate) { + // Atomic implementations of `updateOrCreate` cannot + // provide full instance as that depends on whether + // UPDATE or CREATE will be triggered + observedContexts.should.eql(aTestModelCtx({ + where: { id: 'new-id' }, + data: { id: 'new-id', name: 'a name' } + })); + } else { + // The default unoptimized implementation runs + // `instance.save` and thus a full instance is availalbe + observedContexts.should.eql(aTestModelCtx({ + instance: { id: 'new-id', name: 'a name', extra: undefined } + })); + } + + done(); + }); + }); + + it('applies updates from `before save` hook on update', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.data.name = 'hooked'; + next(); + }); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + instance.name.should.equal('hooked'); + done(); + }); + }); + + it('applies updates from `before save` hook on create', function(done) { + TestModel.observe('before save', function(ctx, next) { + if (ctx.instance) { + ctx.instance.name = 'hooked'; + } else { + ctx.data.name = 'hooked'; + } + next(); + }); + + TestModel.updateOrCreate( + { id: 'new-id', name: 'new name' }, + function(err, instance) { + if (err) return done(err); + instance.name.should.equal('hooked'); + done(); + }); + }); + + // FIXME(bajtos) this fails with connector-specific updateOrCreate + // implementations, see the comment inside lib/dao.js (updateOrCreate) + it.skip('validates model after `before save` hook on update', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + // FIXME(bajtos) this fails with connector-specific updateOrCreate + // implementations, see the comment inside lib/dao.js (updateOrCreate) + it.skip('validates model after `before save` hook on create', function(done) { + TestModel.observe('before save', invalidateTestModel()); + + TestModel.updateOrCreate( + { id: 'new-id', name: 'new name' }, + function(err, instance) { + (err || {}).should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + done(); + }); + }); + + + it('triggers `after save` hook on update', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: existingInstance.id, name: 'updated name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: existingInstance.id, + name: 'updated name', + extra: undefined + }})); + done(); + }); + }); + + it('triggers `after save` hook on create', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.updateOrCreate( + { id: 'new-id', name: 'a name' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ instance: { + id: instance.id, + name: 'a name', + extra: undefined + }})); + done(); + }); + }); + }); + + describe('PersistedModel.deleteAll', function() { + it('triggers `query` hook with query', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.deleteAll({ name: existingInstance.name }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + query: { where: { name: existingInstance.name } } + })); + done(); + }); + }); + + it('triggers `query` hook without query', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.deleteAll(function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { where: {} } })); + done(); + }); + }); + + it('applies updates from `query` hook', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.deleteAll(function(err) { + if (err) return done(err); + findTestModels(function(err, list) { + if (err) return done(err); + (list || []).map(get('id')).should.eql([existingInstance.id]); + done(); + }); + }); + }); + + it('triggers `after delete` hook without query', function(done) { + TestModel.observe('after delete', pushContextAndNext()); + + TestModel.deleteAll(function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ where: {} })); + done(); + }); + }); + + it('triggers `after delete` hook without query', function(done) { + TestModel.observe('after delete', pushContextAndNext()); + + TestModel.deleteAll({ name: existingInstance.name }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { name: existingInstance.name } + })); + done(); + }); + }); + + it('aborts when `after delete` hook fails', function(done) { + TestModel.observe('after delete', nextWithError(expectedError)); + + TestModel.deleteAll(function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + }); + + describe('PersistedModel.prototype.delete', function() { + it('triggers `query` hook', function(done) { + TestModel.observe('query', pushContextAndNext()); + + existingInstance.delete(function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + query: { where: { id: existingInstance.id } } + })); + done(); + }); + }); + + it('applies updated from `query` hook', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + existingInstance.delete(function(err) { + if (err) return done(err); + findTestModels(function(err, list) { + if (err) return done(err); + (list || []).map(get('id')).should.eql([existingInstance.id]); + done(); + }); + }); + }); + + it('triggers `after delete` hook', function(done) { + TestModel.observe('after delete', pushContextAndNext()); + + existingInstance.delete(function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { id: existingInstance.id } + })); + done(); + }); + }); + + it('triggers `after delete` hook without query', function(done) { + TestModel.observe('after delete', pushContextAndNext()); + + TestModel.deleteAll({ name: existingInstance.name }, function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { name: existingInstance.name } + })); + done(); + }); + }); + + it('aborts when `after delete` hook fails', function(done) { + TestModel.observe('after delete', nextWithError(expectedError)); + + TestModel.deleteAll(function(err) { + [err].should.eql([expectedError]); + done(); + }); + }); + + it('triggers hooks only once', function(done) { + TestModel.observe('query', pushNameAndNext('query')); + TestModel.observe('after delete', pushNameAndNext('after delete')); + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + existingInstance.delete(function(err) { + if (err) return done(err); + observersCalled.should.eql(['query', 'after delete']); + done(); + }); + }); + }); + + describe('PersistedModel.updateAll', function() { + it('triggers `query` hook', function(done) { + TestModel.observe('query', pushContextAndNext()); + + TestModel.updateAll( + { name: 'searched' }, + { name: 'updated' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ query: { + where: { name: 'searched' } + }})); + done(); + }); + }); + + it('applies updates from `query` hook', function(done) { + TestModel.observe('query', function(ctx, next) { + ctx.query = { where: { id: { neq: existingInstance.id } } }; + next(); + }); + + TestModel.updateAll( + { id: existingInstance.id }, + { name: 'new name' }, + function(err) { + if (err) return done(err); + findTestModels({ fields: ['id', 'name' ] }, function(err, list) { + if (err) return done(err); + (list||[]).map(toObject).should.eql([ + { id: existingInstance.id, name: existingInstance.name, extra: undefined }, + { id: '2', name: 'new name', extra: undefined } + ]); + done(); + }); + }); + }); + + it('triggers `before save` hook', function(done) { + TestModel.observe('before save', pushContextAndNext()); + + TestModel.updateAll( + { name: 'searched' }, + { name: 'updated' }, + function(err, instance) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { name: 'searched' }, + data: { name: 'updated' }, + })); + done(); + }); + }); + + it('applies updates from `before save` hook', function(done) { + TestModel.observe('before save', function(ctx, next) { + ctx.data = { name: 'hooked', extra: 'added' }; + next(); + }); + + TestModel.updateAll( + { id: existingInstance.id }, + { name: 'updated name' }, + function(err) { + if (err) return done(err); + loadTestModel(existingInstance.id, function(err, instance) { + if (err) return done(err); + instance.should.have.property('name', 'hooked'); + instance.should.have.property('extra', 'added'); + done(); + }); + }); + }); + + it('triggers `after save` hook', function(done) { + TestModel.observe('after save', pushContextAndNext()); + + TestModel.updateAll( + { id: existingInstance.id }, + { name: 'updated name' }, + function(err) { + if (err) return done(err); + observedContexts.should.eql(aTestModelCtx({ + where: { id: existingInstance.id }, + data: { name: 'updated name' } + })); + done(); + }); + }); + }); + + function pushContextAndNext() { + return function(context, next) { + context = deepCloneToObject(context); + + if (typeof observedContexts === 'string') { + observedContexts = context; + return next(); + } + + if (!Array.isArray(observedContexts)) { + observedContexts = [observedContexts]; + } + + observedContexts.push(context); + next(); + }; + } + + function pushNameAndNext(name) { + return function(context, next) { + observersCalled.push(name); + next(); + }; + } + + function nextWithError(err) { + return function(context, next) { + next(err); + }; + } + + function invalidateTestModel() { + return function(context, next) { + if (context.instance) { + context.instance.name = ''; + } else { + context.data.name = ''; + } + next(); + }; + } + + function aTestModelCtx(ctx) { + ctx.Model = TestModel; + return deepCloneToObject(ctx); + } + + function findTestModels(query, cb) { + if (cb === undefined && typeof query === 'function') { + cb = query; + query = null; + } + + TestModel.find(query, { notify: false }, cb); + } + + function loadTestModel(id, cb) { + TestModel.findOne({ where: { id: id } }, { notify: false }, cb); + } + + function uid() { + lastId += 1; + return '' + lastId; + } + }); + + function deepCloneToObject(obj) { + return traverse(obj).map(function(x) { + if (x && x.toObject) + return x.toObject(true); + if (x && typeof x === 'function' && x.modelName) + return '[ModelCtor ' + x.modelName + ']'; + }); + } + + function get(propertyName) { + return function(obj) { + return obj[propertyName]; + }; + } + + function toObject(obj) { + return obj.toObject ? obj.toObject() : obj; + } +};