Skip to content

Commit 1428fa0

Browse files
authored
Added support for model specific hooks (#166)
refs TryGhost/Arch#73 Up until now we have used hooks at a global level, where we intercept the plugin to add logic for particular table names. But we now have a usecase where we want to add certain logic based on properties of the model. This extends the plugin to be able to read hooks from the model. It is backwards compatible, so will continue to work as-is for existing setups. Note that we do not use the _.has helper as this only read properties directly from the object, and not from the prototype chain, which will not work for models which would have these hooks on the prototype.
1 parent 88e9fbd commit 1428fa0

File tree

3 files changed

+272
-5
lines changed

3 files changed

+272
-5
lines changed

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,33 @@ or
2828
| editRelations | Boolean | true | If `false` value is passed in the plugin will not edit the properties of related models unless specified otherwise on model-level `relationshipConfig` through `editable` flag. |
2929
| extendChanged | String | - | Define a variable name and Bookshelf-relations will store the information which relations were changed. |
3030
| attachPreviousRelations | Boolean | false | An option to attach previous relations. Bookshelf-relations attaches this information as `_previousRelations` on the target parent model. |
31-
| hooks | Object | - | <ul><li>**belongsToMany**: Hook into the process of updating belongsToMany relationships. </ul> <br><br> **Example**: ```hooks: {belongsToMany: {after: Function, beforeRelationCreation: Function}}``` |
31+
| hooks | Object | - | <ul><li>**belongsToMany**: Hook into the process of updating belongsToMany relationships. </ul> <br><br> **Example**: ```hooks: {belongsToMany: {after: Function, before: Function}}``` |
3232

3333
Take a look [at the plugin configuration in Ghost](https://github.com/TryGhost/Ghost/blob/2.21.0/core/server/models/base/index.js#L52).
3434

35+
## Hooks
36+
37+
Hooks can be defined globally on the plugin options as described above, or they can be defined on a model by model basis.
38+
A model hook will replace a global hook if present - only one of them will run.
39+
40+
Hook should have a structure like so:
41+
42+
```js
43+
hooks: {
44+
belongsToMany: {
45+
before() {},
46+
after() {}
47+
}
48+
}
49+
```
50+
51+
The hooks we support are:
52+
- `belongsToMany`
53+
- `before` / `beforeRelationCreated`
54+
- `after` / `afterRelationCreated`
55+
56+
Either name can be used but the shorter name will be preferred if both exist.
57+
3558
## Automatic
3659

3760
The plugin will automatically deal with relationships upserts and cascading deletions through hasMany relationships.

lib/relations.js

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,40 @@ class Relations {
304304
}
305305
});
306306

307+
function getFn(obj, nameA, nameB) {
308+
if (!obj) {
309+
return null;
310+
}
311+
if (typeof obj[nameA] === 'function') {
312+
return {
313+
fn: obj[nameA],
314+
name: nameA
315+
};
316+
}
317+
if (typeof obj[nameB] === 'function') {
318+
return {
319+
fn: obj[nameB],
320+
name: nameB
321+
};
322+
}
323+
return null;
324+
}
325+
326+
function getHook(name, fallbackName) {
327+
const globalHook = getFn(pluginOptions?.hooks?.belongsToMany, name, fallbackName);
328+
const modelHook = getFn(model?.hooks?.belongsToMany, name, fallbackName);
329+
330+
if (modelHook) {
331+
return modelHook.fn.bind(model);
332+
}
333+
334+
if (globalHook) {
335+
return globalHook.fn;
336+
}
337+
338+
return null;
339+
}
340+
307341
return Promise.resolve()
308342
.then(function () {
309343
if (!targetsToAttach.length) {
@@ -312,8 +346,9 @@ class Relations {
312346

313347
// NOTE: listen on created target models and allow to hook into the process
314348
existingTargets.on('creating', (collection, data) => {
315-
if (_.has(pluginOptions, 'hooks.belongsToMany.beforeRelationCreation')) {
316-
return pluginOptions.hooks.belongsToMany.beforeRelationCreation(collection, data, opts);
349+
const hook = getHook('before', 'beforeRelationCreation');
350+
if (hook) {
351+
return hook(collection, data, opts);
317352
}
318353
});
319354

@@ -358,8 +393,9 @@ class Relations {
358393
return existingTargets.detach(targetToDetach, _.pick(opts, ['transacting']));
359394
});
360395
}).then(() => {
361-
if (_.has(pluginOptions, 'hooks.belongsToMany.after')) {
362-
return pluginOptions.hooks.belongsToMany.after(existingTargets, targets, opts);
396+
const hook = getHook('after', 'afterRelationCreated');
397+
if (hook) {
398+
return hook(existingTargets, targets, opts);
363399
}
364400
});
365401
})

test/integration/hooks_spec.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
const testUtils = require('../utils');
2+
3+
const _ = require('lodash');
4+
const Bookshelf = require('bookshelf');
5+
const errors = require('@tryghost/errors');
6+
7+
function setupModel(knex, hookConfig) {
8+
const bookshelf = Bookshelf(knex);
9+
const result = {
10+
globalAfterHookCalled: false,
11+
globalBeforeHookCalled: false,
12+
modelAfterHookCalled: false,
13+
modelBeforeHookCalled: false
14+
};
15+
16+
bookshelf.plugin(require('../../lib/plugin'), {
17+
editRelations: false,
18+
extendChanged: '_changed',
19+
hooks: {
20+
belongsToMany: {
21+
after: hookConfig.global.after ? function () {
22+
result.globalAfterHookCalled = true;
23+
} : null,
24+
beforeRelationCreation: hookConfig.global.before ? function () {
25+
result.globalBeforeHookCalled = true;
26+
} : null
27+
}
28+
}
29+
});
30+
31+
let Tag = bookshelf.Model.extend({
32+
tableName: 'tags',
33+
requireFetch: false,
34+
35+
initialize: function () {
36+
this.on('saving', function (model) {
37+
const allowedFields = ['id', 'slug'];
38+
39+
_.each(model.toJSON(), (value, key) => {
40+
if (allowedFields.indexOf(key) === -1) {
41+
model.unset(key);
42+
}
43+
});
44+
45+
if (model.get('slug').length === 0) {
46+
throw new errors.ValidationError({message: 'Slug should not be empty'});
47+
}
48+
});
49+
}
50+
});
51+
52+
let Post = bookshelf.Model.extend({
53+
tableName: 'posts',
54+
55+
hooks: {
56+
belongsToMany: {
57+
after: hookConfig.model.after ? function () {
58+
result.modelAfterHookCalled = true;
59+
} : null,
60+
beforeRelationCreation: hookConfig.model.before ? function () {
61+
result.modelBeforeHookCalled = true;
62+
} : null
63+
}
64+
},
65+
66+
relationships: ['tags'],
67+
68+
relationshipConfig: {
69+
tags: {
70+
editable: true
71+
}
72+
},
73+
74+
initialize: function () {
75+
bookshelf.Model.prototype.initialize.call(this);
76+
77+
this.on('updating', function (model) {
78+
model._changed = _.cloneDeep(model.changed);
79+
});
80+
},
81+
82+
tags: function () {
83+
return this.belongsToMany('Tag', 'posts_tags', 'post_id', 'tag_id').withPivot('sort_order').query('orderBy', 'sort_order', 'ASC');
84+
}
85+
}, {
86+
add: function (data, options) {
87+
options = options || {};
88+
89+
return bookshelf.transaction((transacting) => {
90+
options.transacting = transacting;
91+
92+
let post = this.forge(data);
93+
return post.save(null, options);
94+
});
95+
},
96+
97+
edit: function (data, options) {
98+
return bookshelf.transaction((transacting) => {
99+
let post = this.forge(_.pick(data, 'id'));
100+
101+
return post.fetch(_.merge({transacting: transacting}, options))
102+
.then((dbPost) => {
103+
if (!dbPost) {
104+
throw new Error('Post does not exist');
105+
}
106+
107+
return dbPost.save(_.omit(data, 'id'), _.merge({transacting: transacting}, options));
108+
});
109+
});
110+
},
111+
112+
destroy: function (data) {
113+
return bookshelf.transaction((transacting) => {
114+
return this.forge(_.pick(data, 'id'))
115+
.destroy({transacting: transacting});
116+
});
117+
}
118+
});
119+
120+
result.Tag = bookshelf.model('Tag', Tag);
121+
result.Post = bookshelf.model('Post', Post);
122+
return result;
123+
}
124+
125+
describe('[Integration] Hooks: Posts/Tags', function () {
126+
let authorId;
127+
beforeEach(function () {
128+
return testUtils.database.reset()
129+
.then(function () {
130+
return testUtils.database.init();
131+
})
132+
.then(function () {
133+
const knex = testUtils.database.getConnection();
134+
return knex('authors').select('id').first().then((row) => {
135+
authorId = row.id;
136+
});
137+
});
138+
});
139+
140+
it('Uses the model hooks if all hooks present', function () {
141+
const result = setupModel(testUtils.database.getConnection(), {
142+
global: {
143+
before: true,
144+
after: true
145+
},
146+
model: {
147+
before: true,
148+
after: true
149+
}
150+
});
151+
152+
return result.Post.add({
153+
author_id: authorId,
154+
tags: ['blah']
155+
}).then(() => {
156+
should.equal(result.globalAfterHookCalled, false);
157+
should.equal(result.globalBeforeHookCalled, false);
158+
should.equal(result.modelAfterHookCalled, true);
159+
should.equal(result.modelBeforeHookCalled, true);
160+
});
161+
});
162+
163+
it('Uses the global hooks if no model hook there', function () {
164+
const result = setupModel(testUtils.database.getConnection(), {
165+
global: {
166+
before: true,
167+
after: true
168+
},
169+
model: {
170+
before: false,
171+
after: false
172+
}
173+
});
174+
175+
return result.Post.add({
176+
author_id: authorId,
177+
tags: ['blah']
178+
}).then(() => {
179+
should.equal(result.globalAfterHookCalled, true);
180+
should.equal(result.globalBeforeHookCalled, true);
181+
should.equal(result.modelAfterHookCalled, false);
182+
should.equal(result.modelBeforeHookCalled, false);
183+
});
184+
});
185+
186+
it('Uses the model hooks when available', function () {
187+
const result = setupModel(testUtils.database.getConnection(), {
188+
global: {
189+
before: true,
190+
after: true
191+
},
192+
model: {
193+
before: false,
194+
after: true
195+
}
196+
});
197+
198+
return result.Post.add({
199+
author_id: authorId,
200+
tags: ['blah']
201+
}).then(() => {
202+
should.equal(result.globalAfterHookCalled, false);
203+
should.equal(result.globalBeforeHookCalled, true);
204+
should.equal(result.modelAfterHookCalled, true);
205+
should.equal(result.modelBeforeHookCalled, false);
206+
});
207+
});
208+
});

0 commit comments

Comments
 (0)