From 58a6ca3ab4dc3983cbf4355f7b7a6698ab949359 Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Sun, 8 Nov 2015 15:45:00 -0800 Subject: [PATCH 1/2] Trigger change events in parent object when related collection changes externally This makes sense because a collection changing externally is the equivalent of a parent set() operation for the collection's key. This enables scenarios like listening for changes on a parent object and persisting them without listening for changes to each of the parent object's relationed collections separately. --- backbone-relational.js | 23 +++++++++++++- test/tests.js | 68 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/backbone-relational.js b/backbone-relational.js index ad4c1a17..3decdb50 100755 --- a/backbone-relational.js +++ b/backbone-relational.js @@ -989,7 +989,8 @@ this.listenTo( collection, 'relational:add', this.handleAddition ) .listenTo( collection, 'relational:remove', this.handleRemoval ) - .listenTo( collection, 'relational:reset', this.handleReset ); + .listenTo( collection, 'relational:reset', this.handleReset ) + .listenTo( collection, 'relational:update', this.handleUpdate ); return collection; }, @@ -1139,6 +1140,16 @@ }); }, + handleUpdate: function ( coll, options ) { + // If this change is not inside a set (an onChanged handler), ensure that + // change events for the key and the object are queued. + var dit = this; + !options.silent && !dit.instance._relationalModelChangingViaSet && Backbone.Relational.eventQueue.add( function() { + dit.instance.trigger( 'change:' + dit.key, dit.instance, dit.related, options, true ); + dit.instance.trigger( 'change', dit.instance, options, null, true ); + }); + }, + tryAddRelated: function( model, coll, options ) { var item = _.contains( this.keyIds, model.id ); @@ -1505,6 +1516,7 @@ }, set: function( key, value, options ) { + this._relationalModelChangingViaSet = true; Backbone.Relational.eventQueue.block(); // Duplicate backbone's behavior to allow separate key/value parameters, instead of a single 'attributes' object @@ -1554,6 +1566,7 @@ Backbone.Relational.eventQueue.unblock(); } + this._relationalModelChangingViaSet = false; return result; }, @@ -1973,6 +1986,10 @@ } } + if ( !_.isEmpty( toAdd ) ) { + this.trigger('relational:update', this, options) + } + return result; }; @@ -2000,6 +2017,10 @@ this.trigger( 'relational:remove', model, this, options ); }, this ); + if ( !_.isEmpty( toRemove ) ) { + this.trigger('relational:update', this, options); + } + return result; }; diff --git a/test/tests.js b/test/tests.js index 8151394d..3e897b96 100644 --- a/test/tests.js +++ b/test/tests.js @@ -1639,6 +1639,61 @@ $(document).ready(function() { equal( changedAttrs.color, 'red', '... with correct properties in "changedAttributes"' ); }); + test( 'collection updates should fire change events on parent objects', function() { + var scope = {}; + Backbone.Relational.store.addModelScope( scope ); + + scope.PetAnimal = Backbone.RelationalModel.extend({ + subModelTypes: { + 'cat': 'Cat', + 'dog': 'Dog' + } + }); + scope.Dog = scope.PetAnimal.extend(); + scope.Cat = scope.PetAnimal.extend(); + + scope.PetOwner = Backbone.RelationalModel.extend({ + relations: [{ + type: Backbone.HasMany, + key: 'pets', + relatedModel: scope.PetAnimal, + reverseRelation: { + key: 'owner' + } + }] + }); + + var owner = new scope.PetOwner( { id: 'owner-2354' } ); + var animal = new scope.Dog( { type: 'dog', id: '238902', color: 'blue' } ); + owner.set(owner.parse({ + id: 'owner-2354', + pets: [ animal ] + })); + + var pets = owner.get('pets'); + equal( pets.size(), 1, 'pets starts with one animal' ); + + var changes = 0; + owner.on('change change:pets', function () { + changes++; + }); + pets.add( { id: '456780', type: 'dog', color: 'yellow' } ); + equal( pets.size(), 2, 'added one animal to pets' ); + equal( changes, 2, 'change events get called on owner for pets collection change' ); + + pets.remove( '456780' ); + equal( pets.size(), 1, 'removed one animal from pets' ); + equal( changes, 4, 'change events get called on owner for pets collection change' ); + + var animal2 = new scope.Dog( { id: '34567', type: 'dog', color: 'black' } ); + owner.set(owner.parse({ + id: 'owner-2354', + pets: [ animal, animal2 ] + })); + equal( pets.size(), 2, 'new array of pets is of size 2' ); + equal( changes, 6, 'change events get called on owner for pets collection set via owner.set' ); + }); + test( 'change events should not fire on new items in Collection#set', function() { var modelChangeEvents = 0, collectionChangeEvents = 0; @@ -4657,8 +4712,8 @@ $(document).ready(function() { change = changeAnimals = animalChange = 0; animals.add( 'a2' ); - ok( change === 0, 'change event not should fire' ); - ok( changeAnimals === 0, 'no change:animals event should fire' ); + ok( change === 1, 'change event should fire' ); + ok( changeAnimals === 1, 'change:animals event should fire' ); ok( animalChange === 0, 'no animals:change event should fire' ); // Update an animal directly @@ -4673,8 +4728,8 @@ $(document).ready(function() { change = changeAnimals = animalChange = 0; animals.remove( 'a2' ); - ok( change === 0, 'no change event should fire' ); - ok( changeAnimals === 0, 'no change:animals event should fire' ); + ok( change === 1, 'change event should fire' ); + ok( changeAnimals === 1, 'change:animals event should fire' ); ok( animalChange === 0, 'no animals:change event should fire' ); }); @@ -4772,6 +4827,8 @@ $(document).ready(function() { person .on('change:livesIn', function() { //console.log( arguments ); + + // Will cause a second change notification in `house` house.set({livesIn: house}); }) .on( 'change', function () { @@ -4779,9 +4836,10 @@ $(document).ready(function() { changeEventsTriggered++; }); + // Will cause a change notification in `house` person.set({livesIn: house}); - ok( changeEventsTriggered === 2, 'one change each triggered for `house` and `person`' ); + ok( changeEventsTriggered === 3, 'one change triggered for `person`, two for `house`' ); }); module( "Performance", { setup: reset } ); From ed3d04c9c5ff7f3316827f39813fcd219547ace4 Mon Sep 17 00:00:00 2001 From: Shaheen Gandhi Date: Sat, 28 Nov 2015 15:14:09 -0500 Subject: [PATCH 2/2] Add fetchRelationships option to fetch() to recursively fetch related models --- backbone-relational.js | 20 ++++++++++++++++++++ test/tests.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/backbone-relational.js b/backbone-relational.js index 3decdb50..eae5263c 100755 --- a/backbone-relational.js +++ b/backbone-relational.js @@ -1570,6 +1570,26 @@ return result; }, + fetch: function( options ) { + var dit = this; + var args = arguments; + var promise = Backbone.Model.prototype.fetch.apply( dit, arguments ); + if ( options && options.fetchRelationships && !_.isEmpty( dit._relations ) ) { + promise = promise.then( function( value ) { + var relationshipPromises = _.map( dit._relations, function( rel ) { + return dit.getAsync( rel.key, _.omit( options, 'fetchRelationships' ) ); + }); + relationshipPromises.splice(0, 0, new Promise( function( resolve, reject ) { + resolve( value ); + })); + return Promise.all( relationshipPromises ); + }).then( function( values ) { + return values[0]; + }); + } + return promise; + }, + clone: function() { var attributes = _.clone( this.attributes ); if ( !_.isUndefined( attributes[ this.idAttribute ] ) ) { diff --git a/test/tests.js b/test/tests.js index 3e897b96..eaa40311 100644 --- a/test/tests.js +++ b/test/tests.js @@ -1137,6 +1137,40 @@ $(document).ready(function() { _.last( window.requests ).respond( 200, { id: 'f-2', name: 'Cheese' } ); }); + test( "fetchRelationships recursively fetches relationships", function() { + var personCounter = 0; + var Person = Backbone.RelationalModel.extend({ + urlRoot: '/people/', + // This is ultimately what the server would return when asked for a + // single person model, but there's no way to specify nested responses + // in the test framework. The value is just faked here with stateful + // parsing. + parse: function (resp, options) { + personCounter++; + return {id: "person"+personCounter}; + } + }); + var People = Backbone.Collection.extend({ + model: Person, + url: '/people/' + }); + var Family = Backbone.RelationalModel.extend({ + urlRoot: '/families/', + relations: [ + { + type: Backbone.HasMany, + key: 'members', + collectionType: People, + relatedModel: Person + } + ], + }); + + var family = new Family({id: 'family1'}); + family.fetch( {fetchRelationships: true, response: {status: 200, responseText: {id: "family1", members: ["person1", "person2"]}}} ); + equal( window.requests.length, 3, "fetched family1, person1, and person2" ); + }); + test( "autoFetch a HasMany relation", function() { var shopOne = new Shop({ id: 'shop-1',