diff --git a/README.md b/README.md index fec0d5b..ca641d3 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,8 @@ Will send GET to /bikes/1/parts/:id instead of /parts/:id +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. + #### HasOne @@ -579,6 +581,8 @@ owner.$save(); +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. + #### BelongsTo diff --git a/docs/guides/hooks.md b/docs/guides/hooks.md index d9e348f..636d4d9 100644 --- a/docs/guides/hooks.md +++ b/docs/guides/hooks.md @@ -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', { diff --git a/src/module/extended/builder-relations.js b/src/module/extended/builder-relations.js index f17f39e..3a43e87 100644 --- a/src/module/extended/builder-relations.js +++ b/src/module/extended/builder-relations.js @@ -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 * @@ -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') { @@ -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); @@ -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') { @@ -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; @@ -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']); }); diff --git a/test/relation-spec.js b/test/relation-spec.js index 6d323d9..d2a7906 100644 --- a/test/relation-spec.js +++ b/test/relation-spec.js @@ -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' } } }); }); @@ -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); @@ -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() {