Skip to content

Commit

Permalink
feat(relations): adds hasMany and hasOne hooks.
Browse files Browse the repository at this point in the history
Also adds hasMany `params` option.

Closes #35, #28
  • Loading branch information
iobaixas committed Nov 26, 2014
1 parent db47f98 commit fc2a805
Show file tree
Hide file tree
Showing 4 changed files with 195 additions and 18 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,8 @@ Will send GET to /bikes/1/parts/:id instead of /parts/:id

<!-- end -->

You can also define `hasMany` relation hooks. Check the [hooks advanced documentation](https://github.com/platanus/angular-restmod/blob/master/docs/guides/hooks.md) for more information.

<!-- end -->

#### HasOne
Expand Down Expand Up @@ -579,6 +581,8 @@ owner.$save();
<!-- end -->

You can also define `hasOne` relation hooks. Check the [hooks advanced documentation](https://github.com/platanus/angular-restmod/blob/master/docs/guides/hooks.md) for more information.

<!-- ignore -->

#### BelongsTo
Expand Down
58 changes: 57 additions & 1 deletion docs/guides/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,63 @@ bike = bikes.$new();
bike.$save(); // this call will trigger the hook defined above
```

##### 4. Hooks that apply only to a certain execution context using `$decorate`, used mainly to modify a method behaviour
##### 4. Hooks that apply to a relation object

The `hasMany` and `hasOne` relations support hooks, these can be defined in the relation definition or in the type config block

In the relation definition:

```javascript
var Bike = $restmod.model('/bikes'.$mix({
parts: {
hasMany: 'Part',
hooks: {
'after-fetch-many': function() { /* do something with the new parts */ }
}
}
});
```
or in the configuration block (applies to every relation where the extended class is the **NESTED** resource):
```javascript
Var Part = $restmod.model().$mix({
$config {
hasMany: {
hooks: {
'after-has-many-init': function() {
// Here we are using the init hook to override every hasMany Part relation scope.
// This can be useful if a custom url scheme is needed for relations.
this.$scope = customScope(this.$scope);
}
}
}
}
})
```
To access the owner object inside a relation hook, use the child object's `$owner` property:
```javascript
var Bike = $restmod.model('/bikes').$mix({
parts: {
hasMany: 'Part',
hooks: {
'after-fetch-many': function() {
this.$owner.partsCount = this.length; // update the owner partCount every time the relation is fetched!
}
}
}
});
```
Both `hasOne` and `hasMany` trigger an special event after hooks are added to the child resource, this enables you to specify some
logic to be run after relation resource is initialized (since hooks are added AFTER `after-init` and `after-collection-init` are triggered).
* `hasMany` triggers `after-has-many-init` after `after-collection-init`.
* `hasOne` triggers `after-has-one-init` after `after-init`.
##### 5. Hooks that apply only to a certain execution context using `$decorate`, used mainly to modify a method behaviour
```javascript
var Bike = $restmod.model('/bikes', {
Expand Down
70 changes: 54 additions & 16 deletions src/module/extended/builder-relations.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUtils', 'restmod', 'RMPackerCache', function($injector, inflector, $log, Utils, restmod, packerCache) {

// wraps a hook callback to give access to the $owner object
function wrapHook(_fun, _owner) {
return function() {
var oldOwner = this.$owner;
this.$owner = _owner;
try {
return _fun.apply(this, arguments);
} finally {
this.$owner = oldOwner;
}
};
}

// wraps a bunch of hooks
function applyHooks(_target, _hooks, _owner) {
for(var key in _hooks) {
if(_hooks.hasOwnProperty(key)) {
_target.$on(key, wrapHook(_hooks[key], _owner));
}
}
}

/**
* @class RelationBuilderApi
*
Expand All @@ -27,15 +49,23 @@ RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUti
* @param {string} _url Partial url
* @param {string} _source Inline resource alias (optional)
* @param {string} _inverseOf Inverse property name (optional)
* @param {object} _params Generated collection default parameters
* @param {object} _hooks Hooks to be applied just to the generated collection
* @return {BuilderApi} self
*/
attrAsCollection: function(_attr, _model, _url, _source, _inverseOf) {
attrAsCollection: function(_attr, _model, _url, _source, _inverseOf, _params, _hooks) {

var options, globalHooks; // global relation configuration

this.attrDefault(_attr, function() {

if(typeof _model === 'string') {
_model = $injector.get(_model);

// retrieve global options
options = _model.getProperty('hasMany', {});
globalHooks = options.hooks;

if(_inverseOf) {
var desc = _model.$$getDescription(_inverseOf);
if(!desc || desc.relation !== 'belongs_to') {
Expand All @@ -45,25 +75,23 @@ RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUti
}
}

var self = this,
scope = this.$buildScope(_model, _url || inflector.parameterize(_attr)),
col = _model.$collection(null, scope);
var scope = this.$buildScope(_model, _url || inflector.parameterize(_attr)), col; // TODO: name to url transformation should be a Model strategy

// TODO: there should be a way to modify scope behavior just for this relation,
// since relation item scope IS the collection, then the collection should
// be extended to provide a modified scope. For this an additional _extensions
// parameters could be added to collection, then these 'extensions' are inherited
// by child collections, the other alternative is to enable full property inheritance ...
// setup collection
col = _model.$collection(_params || null, scope);
if(globalHooks) applyHooks(col, globalHooks, this);
if(_hooks) applyHooks(col, _hooks, this);
col.$dispatch('after-has-many-init');

// set inverse property if required.
if(_inverseOf) {
var self = this;
col.$on('after-add', function(_obj) {
_obj[_inverseOf] = self;
});
}

return col;
// simple support for inline data, TODO: maybe deprecate this.
});

if(_source || _url) this.attrMap(_attr, _source || _url);
Expand All @@ -87,15 +115,22 @@ RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUti
* @param {string} _url Partial url (optional)
* @param {string} _source Inline resource alias (optional)
* @param {string} _inverseOf Inverse property name (optional)
* @param {object} _hooks Hooks to be applied just to the instantiated record
* @return {BuilderApi} self
*/
attrAsResource: function(_attr, _model, _url, _source, _inverseOf) {
attrAsResource: function(_attr, _model, _url, _source, _inverseOf, _hooks) {

var options, globalHooks; // global relation configuration

this.attrDefault(_attr, function() {

if(typeof _model === 'string') {
_model = $injector.get(_model);

// retrieve global options
options = _model.getProperty('hasOne', {});
globalHooks = options.hooks;

if(_inverseOf) {
var desc = _model.$$getDescription(_inverseOf);
if(!desc || desc.relation !== 'belongs_to') {
Expand All @@ -105,10 +140,13 @@ RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUti
}
}

var scope = this.$buildScope(_model, _url || inflector.parameterize(_attr)),
inst = _model.$new(null, scope);
var scope = this.$buildScope(_model, _url || inflector.parameterize(_attr)), inst;

// TODO: provide a way to modify scope behavior just for this relation
// setup record
inst = _model.$new(null, scope);
if(globalHooks) applyHooks(inst, globalHooks, this);
if(_hooks) applyHooks(inst, _hooks, this);
inst.$dispatch('after-has-one-init');

if(_inverseOf) {
inst[_inverseOf] = this;
Expand Down Expand Up @@ -325,8 +363,8 @@ RMModule.factory('RMBuilderRelations', ['$injector', 'inflector', '$log', 'RMUti
};

return restmod.mixin(function() {
this.extend('attrAsCollection', EXT.attrAsCollection, ['hasMany', 'path', 'source', 'inverseOf']) // TODO: rename source to map, but disable attrMap if map is used here...
.extend('attrAsResource', EXT.attrAsResource, ['hasOne', 'path', 'source', 'inverseOf'])
this.extend('attrAsCollection', EXT.attrAsCollection, ['hasMany', 'path', 'source', 'inverseOf', 'params', 'hooks']) // TODO: rename source to map, but disable attrMap if map is used here...
.extend('attrAsResource', EXT.attrAsResource, ['hasOne', 'path', 'source', 'inverseOf', 'hooks'])
.extend('attrAsReference', EXT.attrAsReference, ['belongsTo', 'key', 'prefetch'])
.extend('attrAsReferenceToMany', EXT.attrAsReferenceToMany, ['belongsToMany', 'keys']);
});
Expand Down
81 changes: 80 additions & 1 deletion test/relation-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ describe('Restmod model relation:', function() {
beforeEach(function() {
Bike = restmod.model('/api/bikes', {
allParts: { hasMany: 'Part' },
activity: { hasMany: 'BikeRide', path: 'rides', inverseOf: 'bike' }
activity: { hasMany: 'BikeRide', path: 'rides', inverseOf: 'bike' },
todayActivity: { hasMany: 'BikeRide', path: 'rides', inverseOf: 'bike', params: { since: 'today' } }
});
});

Expand All @@ -53,6 +54,49 @@ describe('Restmod model relation:', function() {
expect(Bike.$new(1).activity.$url()).toEqual('/api/bikes/1/rides');
});

it('should use parameters provided in params option', function() {
Bike = restmod.model('/api/bikes', {
wheels: { hasMany: 'Part', params: { category: 'wheel' } }
});

expect(Bike.$new(1).wheels.$params.category).toEqual('wheel');
});

it('should use hooks provided in hooks option', function() {
var owner, added;
Bike = restmod.model('/api/bikes').mix({
wheels: {
hasMany: 'Part',
hooks: {
'a-hook': function(_value) {
owner = this.$owner;
added = _value;
}
}
}
});

var bike = Bike.$new(1);
bike.wheels.$dispatch('a-hook', ['param1']);
expect(owner).toEqual(bike);
expect(added).toEqual('param1');
});

it('should trigger an after-has-many-init hook on creation', function() {
var spy = jasmine.createSpy();
Bike = restmod.model('/api/bikes').mix({
wheels: {
hasMany: 'Part',
hooks: {
'after-has-many-init': spy
}
}
});

Bike.$new(1);
expect(spy).toHaveBeenCalled();
});

it('should set the inverse property of each child if required', function() {
var bike = Bike.$new(1).$decode({ rides: [{ id: 1 }, { id: 2 }] });
expect(bike.activity[0].bike).toEqual(bike);
Expand Down Expand Up @@ -184,6 +228,41 @@ describe('Restmod model relation:', function() {
var bike = Bike.$new(1).$decode({ serial: { id: 'XX' } });
expect(bike.serialNo.bike).toEqual(bike);
});

it('should use hooks provided in hooks option', function() {
var owner, added;
Bike = restmod.model('/api/bikes').mix({
serial: {
hasOne: 'SerialNo',
hooks: {
'a-hook': function(_value) {
owner = this.$owner;
added = _value;
}
}
}
});

var bike = Bike.$new(1);
bike.serial.$dispatch('a-hook', ['param1']);
expect(owner).toEqual(bike);
expect(added).toEqual('param1');
});

it('should trigger an after-has-many-init hook on creation', function() {
var spy = jasmine.createSpy();
Bike = restmod.model('/api/bikes').mix({
serial: {
hasOne: 'SerialNo',
hooks: {
'after-has-one-init': spy
}
}
});

Bike.$new(1);
expect(spy).toHaveBeenCalled();
});
});

describe('belongsTo', function() {
Expand Down

0 comments on commit fc2a805

Please sign in to comment.