From b86615e2b746030279b587041d2abb6cf85fadea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 12 Apr 2016 14:20:25 +0200 Subject: [PATCH] Implement operation hooks for EmbedsOne methods create() triggers - before save - after save udpate() triggers - before save - after save destroy() triggers - before delete - after delete The implementation here is intentionally left with less features than the regular DAO methods provide, the goal is to get a partial (but still useful!) version released soon. --- lib/relation-definition.js | 107 ++++++++-- test/helpers/hook-monitor.js | 4 + .../embeds-one-create.suite.js | 186 +++++++++++++++++ .../embeds-one-destroy.suite.js | 140 +++++++++++++ .../embeds-one-update.suite.js | 195 ++++++++++++++++++ test/operation-hooks.suite/index.js | 17 ++ test/persistence-hooks.suite.js | 2 + 7 files changed, 631 insertions(+), 20 deletions(-) create mode 100644 test/operation-hooks.suite/embeds-one-create.suite.js create mode 100644 test/operation-hooks.suite/embeds-one-destroy.suite.js create mode 100644 test/operation-hooks.suite/embeds-one-update.suite.js create mode 100644 test/operation-hooks.suite/index.js diff --git a/lib/relation-definition.js b/lib/relation-definition.js index 5e3c9a79..f87c3249 100644 --- a/lib/relation-definition.js +++ b/lib/relation-definition.js @@ -2107,16 +2107,16 @@ EmbedsOne.prototype.create = function(targetModelData, options, cb) { var inst = this.callScopeMethod('build', targetModelData); - var updateEmbedded = function() { + var updateEmbedded = function(callback) { if (modelInstance.isNewRecord()) { modelInstance.setAttribute(propertyName, inst); modelInstance.save(options, function(err) { - cb(err, err ? null : inst); + callback(err, err ? null : inst); }); } else { modelInstance.updateAttribute(propertyName, inst, options, function(err) { - cb(err, err ? null : inst); + callback(err, err ? null : inst); }); } }; @@ -2124,17 +2124,37 @@ EmbedsOne.prototype.create = function(targetModelData, options, cb) { if (this.definition.options.persistent) { inst.save(options, function(err) { // will validate if (err) return cb(err, inst); - updateEmbedded(); + updateEmbedded(cb); }); } else { - var err = inst.isValid() ? null : new ValidationError(inst); - if (err) { - process.nextTick(function() { - cb(err); - }); - } else { - updateEmbedded(); - } + var context = { + Model: modelTo, + instance: inst, + options: options || {}, + hookState: {}, + }; + modelTo.notifyObserversOf('before save', context, function(err) { + if (err) { + return process.nextTick(function() { + cb(err); + }); + } + + var err = inst.isValid() ? null : new ValidationError(inst); + if (err) { + process.nextTick(function() { + cb(err); + }); + } else { + updateEmbedded(function(err, inst) { + if (err) return cb(err); + context.instance = inst; + modelTo.notifyObserversOf('after save', context, function(err) { + cb(err, err ? null : inst); + }); + }); + } + }); } return cb.promise; }; @@ -2174,6 +2194,7 @@ EmbedsOne.prototype.update = function(targetModelData, options, cb) { cb = options; options = {}; } + var modelTo = this.definition.modelTo; var modelInstance = this.modelInstance; var propertyName = this.definition.keyFrom; @@ -2183,13 +2204,39 @@ EmbedsOne.prototype.update = function(targetModelData, options, cb) { var embeddedInstance = modelInstance[propertyName]; if (embeddedInstance instanceof modelTo) { - embeddedInstance.setAttributes(data); cb = cb || utils.createPromiseCallback(); - if (typeof cb === 'function') { - modelInstance.save(options, function(err, inst) { - cb(err, inst ? inst[propertyName] : embeddedInstance); + var hookState = {}; + var context = { + Model: modelTo, + currentInstance: embeddedInstance, + data: data, + options: options || {}, + hookState: hookState, + }; + modelTo.notifyObserversOf('before save', context, function(err) { + if (err) return cb(err); + + embeddedInstance.setAttributes(context.data); + + // TODO support async validations + if (!embeddedInstance.isValid()) { + return cb(new ValidationError(embeddedInstance)); + } + + modelInstance.save(function(err, inst) { + if (err) return cb(err); + + context = { + Model: modelTo, + instance: inst ? inst[propertyName] : embeddedInstance, + options: options || {}, + hookState: hookState, + }; + modelTo.notifyObserversOf('after save', context, function(err) { + cb(err, context.instance); + }); }); - } + }); } else if (!embeddedInstance && cb) { return this.callScopeMethod('create', data, cb); } else if (!embeddedInstance) { @@ -2204,13 +2251,33 @@ EmbedsOne.prototype.destroy = function(options, cb) { cb = options; options = {}; } + cb = cb || utils.createPromiseCallback(); + var modelTo = this.definition.modelTo; var modelInstance = this.modelInstance; var propertyName = this.definition.keyFrom; + var embeddedInstance = modelInstance[propertyName]; + + if (!embeddedInstance) { + cb(); + return cb.promise; + } + modelInstance.unsetAttribute(propertyName, true); - cb = cb || utils.createPromiseCallback(); - modelInstance.save(function(err, result) { - cb && cb(err, result); + + var context = { + Model: modelTo, + instance: embeddedInstance, + options: options || {}, + hookState: {}, + }; + modelTo.notifyObserversOf('before delete', context, function(err) { + if (err) return cb(err); + modelInstance.save(function(err, result) { + if (err) return cb(err); + modelTo.notifyObserversOf('after delete', context, cb); + }); }); + return cb.promise; }; diff --git a/test/helpers/hook-monitor.js b/test/helpers/hook-monitor.js index 384d076b..20af6cbc 100644 --- a/test/helpers/hook-monitor.js +++ b/test/helpers/hook-monitor.js @@ -28,3 +28,7 @@ HookMonitor.prototype.install = function(ObservedModel, hookNames) { this._notify.apply(this, arguments); }; }; + +HookMonitor.prototype.resetNames = function() { + this.names = []; +}; diff --git a/test/operation-hooks.suite/embeds-one-create.suite.js b/test/operation-hooks.suite/embeds-one-create.suite.js new file mode 100644 index 00000000..e5329eb9 --- /dev/null +++ b/test/operation-hooks.suite/embeds-one-create.suite.js @@ -0,0 +1,186 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback-datasource-juggler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var ValidationError = require('../..').ValidationError; + +var contextTestHelpers = require('../helpers/context-test-helpers'); +var ContextRecorder = contextTestHelpers.ContextRecorder; +var aCtxForModel = contextTestHelpers.aCtxForModel; + +var uid = require('../helpers/uid-generator'); +var HookMonitor = require('../helpers/hook-monitor'); + +module.exports = function(dataSource, should, connectorCapabilities) { + describe('EmbedsOne - create', function() { + var ctxRecorder, hookMonitor, expectedError; + + beforeEach(function setupHelpers() { + ctxRecorder = new ContextRecorder('hook not called'); + hookMonitor = new HookMonitor({ includeModelName: true }); + expectedError = new Error('test error'); + }); + + var Owner, Embedded, ownerInstance; + var migrated = false; + + beforeEach(function setupDatabase() { + Embedded = dataSource.createModel('Embedded', { + // Set id.generated to false to honor client side values + id: { type: String, id: true, generated: false, default: uid.next }, + name: { type: String, required: true }, + extra: { type: String, required: false }, + }); + + Owner = dataSource.createModel('Owner', {}); + Owner.embedsOne(Embedded); + + hookMonitor.install(Embedded); + hookMonitor.install(Owner); + + if (migrated) { + return Owner.deleteAll(); + } else { + return dataSource.automigrate(Owner.modelName) + .then(function() { migrated = true; }); + } + }); + + beforeEach(function setupData() { + return Owner.create({}).then(function(inst) { + ownerInstance = inst; + hookMonitor.resetNames(); + }); + }); + + function callCreate() { + var item = new Embedded({ name: 'created' }); + return ownerInstance.embeddedItem.create(item); + } + + it('triggers hooks in the correct order', function() { + return callCreate().then(function(result) { + hookMonitor.names.should.eql([ + 'Embedded:before save', + //TODO 'Embedded:persist', + 'Owner:before save', + 'Owner:persist', + 'Owner:loaded', + 'Owner:after save', + //TODO 'Embedded:loaded', + 'Embedded:after save', + ]); + }); + }); + + it('trigers `before save` hook on embedded model', function() { + Embedded.observe('before save', ctxRecorder.recordAndNext()); + return callCreate().then(function(instance) { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + instance: { + id: instance.id, + name: 'created', + extra: undefined, + }, + // TODO isNewInstance: true, + })); + }); + }); + + // TODO + it('trigers `before save` hook on owner model'); + + it('applies updates from `before save` hook', function() { + Embedded.observe('before save', function(ctx, next) { + ctx.instance.should.be.instanceOf(Embedded); + ctx.instance.extra = 'hook data'; + next(); + }); + return callCreate().then(function(instance) { + instance.should.have.property('extra', 'hook data'); + }); + }); + + it('validates model after `before save` hook', function() { + Embedded.observe('before save', invalidateEmbeddedModel); + return callCreate().then(throwShouldHaveFailed, function(err) { + err.should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + }); + }); + + it('aborts when `before save` hook fails', function() { + Embedded.observe('before save', nextWithError(expectedError)); + return callCreate().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + // TODO + it('triggers `persist` hook on embedded model'); + it('triggers `persist` hook on owner model'); + it('applies updates from `persist` hook'); + it('aborts when `persist` hook fails'); + + // TODO + it('triggers `loaded` hook on embedded model'); + it('triggers `loaded` hook on owner model'); + it('applies updates from `loaded` hook'); + it('aborts when `loaded` hook fails'); + + it('triggers `after save` hook on embedded model', function() { + Embedded.observe('after save', ctxRecorder.recordAndNext()); + return callCreate().then(function(instance) { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + instance: { + id: instance.id, + name: 'created', + extra: undefined, + }, + // TODO isNewInstance: true, + })); + }); + }); + + // TODO + it('triggers `after save` hook on owner model'); + + it('applies updates from `after save` hook', function() { + Embedded.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(Embedded); + ctx.instance.extra = 'hook data'; + next(); + }); + return callCreate().then(function(instance) { + instance.should.have.property('extra', 'hook data'); + }); + }); + + it('aborts when `after save` hook fails', function() { + Embedded.observe('after save', nextWithError(expectedError)); + return callCreate().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + function invalidateEmbeddedModel(context, next) { + if (context.instance) { + context.instance.name = ''; + } else { + context.data.name = ''; + } + next(); + } + + function nextWithError(err) { + return function(context, next) { + next(err); + }; + } + + function throwShouldHaveFailed() { + throw new Error('operation should have failed'); + } + }); +}; diff --git a/test/operation-hooks.suite/embeds-one-destroy.suite.js b/test/operation-hooks.suite/embeds-one-destroy.suite.js new file mode 100644 index 00000000..c024e815 --- /dev/null +++ b/test/operation-hooks.suite/embeds-one-destroy.suite.js @@ -0,0 +1,140 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback-datasource-juggler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var ValidationError = require('../..').ValidationError; + +var contextTestHelpers = require('../helpers/context-test-helpers'); +var ContextRecorder = contextTestHelpers.ContextRecorder; +var aCtxForModel = contextTestHelpers.aCtxForModel; + +var uid = require('../helpers/uid-generator'); +var HookMonitor = require('../helpers/hook-monitor'); + +module.exports = function(dataSource, should, connectorCapabilities) { + describe('EmbedsOne - destroy', function() { + var ctxRecorder, hookMonitor, expectedError; + beforeEach(function sharedSetup() { + ctxRecorder = new ContextRecorder('hook not called'); + hookMonitor = new HookMonitor({ includeModelName: true }); + expectedError = new Error('test error'); + }); + + var Owner, Embedded; + var migrated = false; + beforeEach(function setupDatabase() { + Embedded = dataSource.createModel('Embedded', { + // Set id.generated to false to honor client side values + id: { type: String, id: true, generated: false, default: uid.next }, + name: { type: String, required: true }, + extra: { type: String, required: false }, + }); + + Owner = dataSource.createModel('Owner', {}); + Owner.embedsOne(Embedded); + + hookMonitor.install(Embedded); + hookMonitor.install(Owner); + + if (migrated) { + return Owner.deleteAll(); + } else { + return dataSource.automigrate(Owner.modelName) + .then(function() { migrated = true; }); + } + }); + + var ownerInstance, existingInstance; + beforeEach(function setupData() { + return Owner.create({}) + .then(function(inst) { + ownerInstance = inst; + }) + .then(function() { + var item = new Embedded({ name: 'created' }); + return ownerInstance.embeddedItem.create(item).then(function(it) { + existingItem = it; + }); + }) + .then(function() { + hookMonitor.resetNames(); + }); + }); + + function callDestroy() { + return ownerInstance.embeddedItem.destroy(); + } + + it('triggers hooks in the correct order', function() { + return callDestroy().then(function(result) { + hookMonitor.names.should.eql([ + 'Embedded:before delete', + 'Owner:before save', + 'Owner:persist', + 'Owner:loaded', + 'Owner:after save', + 'Embedded:after delete', + ]); + }); + }); + + it('trigers `before delete` hook', function() { + Embedded.observe('before delete', ctxRecorder.recordAndNext()); + return callDestroy().then(function() { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + instance: { + id: existingItem.id, + name: 'created', + extra: undefined, + }, + })); + }); + }); + + // TODO + // In order to allow "before delete" hook to make changes, + // we need to enhance the context to include information + // about the model instance being deleted. + // "ctx.where: { id: embedded.id }" may not be enough, + // as it does not identify the parent (owner) model + it('applies updates from `before delete` hook'); + + it('aborts when `before delete` hook fails', function() { + Embedded.observe('before delete', nextWithError(expectedError)); + return callDestroy().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + it('trigers `after delete` hook', function() { + Embedded.observe('after delete', ctxRecorder.recordAndNext()); + return callDestroy().then(function() { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + instance: { + id: existingItem.id, + name: 'created', + extra: undefined, + }, + })); + }); + }); + + it('aborts when `after delete` hook fails', function() { + Embedded.observe('after delete', nextWithError(expectedError)); + return callDestroy().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + function nextWithError(err) { + return function(context, next) { + next(err); + }; + } + + function throwShouldHaveFailed() { + throw new Error('operation should have failed'); + } + }); +}; diff --git a/test/operation-hooks.suite/embeds-one-update.suite.js b/test/operation-hooks.suite/embeds-one-update.suite.js new file mode 100644 index 00000000..ec8d8e66 --- /dev/null +++ b/test/operation-hooks.suite/embeds-one-update.suite.js @@ -0,0 +1,195 @@ +// Copyright IBM Corp. 2015,2016. All Rights Reserved. +// Node module: loopback-datasource-juggler +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +var ValidationError = require('../..').ValidationError; + +var contextTestHelpers = require('../helpers/context-test-helpers'); +var ContextRecorder = contextTestHelpers.ContextRecorder; +var aCtxForModel = contextTestHelpers.aCtxForModel; + +var uid = require('../helpers/uid-generator'); +var HookMonitor = require('../helpers/hook-monitor'); + +module.exports = function(dataSource, should, connectorCapabilities) { + describe('EmbedsOne - update', function() { + var ctxRecorder, hookMonitor, expectedError; + beforeEach(function setupHelpers() { + ctxRecorder = new ContextRecorder('hook not called'); + hookMonitor = new HookMonitor({ includeModelName: true }); + expectedError = new Error('test error'); + }); + + var Owner, Embedded; + var migrated = false; + beforeEach(function setupDatabase() { + Embedded = dataSource.createModel('Embedded', { + // Set id.generated to false to honor client side values + id: { type: String, id: true, generated: false, default: uid.next }, + name: { type: String, required: true }, + extra: { type: String, required: false }, + }); + + Owner = dataSource.createModel('Owner', {}); + Owner.embedsOne(Embedded); + + hookMonitor.install(Embedded); + hookMonitor.install(Owner); + + if (migrated) { + return Owner.deleteAll(); + } else { + return dataSource.automigrate(Owner.modelName) + .then(function() { migrated = true; }); + } + }); + + var ownerInstance, existingItem; + beforeEach(function setupData() { + return Owner.create({}) + .then(function(inst) { + ownerInstance = inst; + }) + .then(function() { + var item = new Embedded({ name: 'created' }); + return ownerInstance.embeddedItem.create(item).then(function(it) { + existingItem = it; + }); + }) + .then(function() { + hookMonitor.resetNames(); + }); + }); + + function callUpdate() { + return ownerInstance.embeddedItem.update({ name: 'updated' }); + } + + it('triggers hooks in the correct order', function() { + return callUpdate().then(function(result) { + hookMonitor.names.should.eql([ + 'Embedded:before save', + //TODO 'Embedded:persist', + 'Owner:before save', + 'Owner:persist', + 'Owner:loaded', + 'Owner:after save', + //TODO 'Embedded:loaded', + 'Embedded:after save', + ]); + }); + }); + + it('trigers `before save` hook on embedded model', function() { + Embedded.observe('before save', ctxRecorder.recordAndNext()); + return callUpdate().then(function(instance) { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + currentInstance: { + id: instance.id, + name: 'created', + extra: undefined, + }, + data: { + name: 'updated', + }, + // TODO isNewInstance: true, + })); + }); + }); + + // TODO + it('trigers `before save` hook on owner model'); + + it('applies updates from `before save` hook', function() { + Embedded.observe('before save', function(ctx, next) { + ctx.data.extra = 'hook data'; + next(); + }); + return callUpdate().then(function(instance) { + instance.should.have.property('extra', 'hook data'); + }); + }); + + it('validates model after `before save` hook', function() { + Embedded.observe('before save', invalidateEmbeddedModel); + return callUpdate().then(throwShouldHaveFailed, function(err) { + err.should.be.instanceOf(ValidationError); + (err.details.codes || {}).should.eql({ name: ['presence'] }); + }); + }); + + it('aborts when `before save` hook fails', function() { + Embedded.observe('before save', nextWithError(expectedError)); + return callUpdate().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + // TODO + it('triggers `persist` hook on embedded model'); + it('triggers `persist` hook on owner model'); + it('applies updates from `persist` hook'); + it('aborts when `persist` hook fails'); + + // TODO + it('triggers `loaded` hook on embedded model'); + it('triggers `loaded` hook on owner model'); + it('applies updates from `loaded` hook'); + it('aborts when `loaded` hook fails'); + + it('triggers `after save` hook on embedded model', function() { + Embedded.observe('after save', ctxRecorder.recordAndNext()); + return callUpdate().then(function(instance) { + ctxRecorder.records.should.eql(aCtxForModel(Embedded, { + instance: { + id: instance.id, + name: 'updated', + extra: undefined, + }, + // TODO isNewInstance: true, + })); + }); + }); + + // TODO + it('triggers `after save` hook on owner model'); + + it('applies updates from `after save` hook', function() { + Embedded.observe('after save', function(ctx, next) { + ctx.instance.should.be.instanceOf(Embedded); + ctx.instance.extra = 'hook data'; + next(); + }); + return callUpdate().then(function(instance) { + instance.should.have.property('extra', 'hook data'); + }); + }); + + it('aborts when `after save` hook fails', function() { + Embedded.observe('after save', nextWithError(expectedError)); + return callUpdate().then(throwShouldHaveFailed, function(err) { + err.should.eql(expectedError); + }); + }); + + function invalidateEmbeddedModel(context, next) { + if (context.instance) { + context.instance.name = ''; + } else { + context.data.name = ''; + } + next(); + } + + function nextWithError(err) { + return function(context, next) { + next(err); + }; + } + + function throwShouldHaveFailed() { + throw new Error('operation should have failed'); + } + }); +}; diff --git a/test/operation-hooks.suite/index.js b/test/operation-hooks.suite/index.js new file mode 100644 index 00000000..1b9fcab7 --- /dev/null +++ b/test/operation-hooks.suite/index.js @@ -0,0 +1,17 @@ +var debug = require('debug')('test'); +var fs = require('fs'); +var path = require('path'); + +module.exports = function(dataSource, should, connectorCapabilities) { + var operations = fs.readdirSync(__dirname); + operations = operations.filter(function(it) { + return it !== path.basename(__filename) && + !!require.extensions[path.extname(it).toLowerCase()]; + }); + for (var ix in operations) { + var name = operations[ix]; + var fullPath = require.resolve('./' + name); + debug('Loading test suite %s (%s)', name, fullPath); + require(fullPath).apply(this, arguments); + } +}; diff --git a/test/persistence-hooks.suite.js b/test/persistence-hooks.suite.js index b463ffa4..e8b0333f 100644 --- a/test/persistence-hooks.suite.js +++ b/test/persistence-hooks.suite.js @@ -2976,6 +2976,8 @@ module.exports = function(dataSource, should, connectorCapabilities) { function monitorHookExecution(hookNames) { hookMonitor.install(TestModel, hookNames); } + + require('./operation-hooks.suite')(dataSource, should, connectorCapabilities); }); function get(propertyName) {