frontend 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * Backbone-relational.js 0.4.0
3
+ * (c) 2011 Paul Uithol
4
+ *
5
+ * Backbone-relational may be freely distributed under the MIT license.
6
+ * For details and documentation: https://github.com/PaulUithol/Backbone-relational.
7
+ * Depends on (as in, compeletely useless without) Backbone: https://github.com/documentcloud/backbone.
8
+ */
9
+ (function(undefined) {
10
+
11
+ /**
12
+ * CommonJS shim
13
+ **/
14
+ if (typeof window === 'undefined') {
15
+ var _ = require('underscore');
16
+ var Backbone = require('backbone');
17
+ var exports = module.exports = Backbone;
18
+ } else {
19
+ var _ = this._;
20
+ var Backbone = this.Backbone;
21
+ var exports = this;
22
+ }
23
+
24
+ Backbone.Relational = {};
25
+
26
+ /**
27
+ * Semaphore mixin; can be used as both binary and counting.
28
+ **/
29
+ Backbone.Semaphore = {
30
+ _permitsAvailable: null,
31
+ _permitsUsed: 0,
32
+
33
+ acquire: function() {
34
+ if ( this._permitsAvailable && this._permitsUsed >= this._permitsAvailable ) {
35
+ throw new Error('Max permits acquired');
36
+ }
37
+ else {
38
+ this._permitsUsed++;
39
+ }
40
+ },
41
+
42
+ release: function() {
43
+ if ( this._permitsUsed === 0 ) {
44
+ throw new Error('All permits released');
45
+ }
46
+ else {
47
+ this._permitsUsed--;
48
+ }
49
+ },
50
+
51
+ isLocked: function() {
52
+ return this._permitsUsed > 0;
53
+ },
54
+
55
+ setAvailablePermits: function( amount ) {
56
+ if ( this._permitsUsed > amount ) {
57
+ throw new Error('Available permits cannot be less than used permits');
58
+ }
59
+ this._permitsAvailable = amount;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * A BlockingQueue that accumulates items while blocked (via 'block'),
65
+ * and processes them when unblocked (via 'unblock').
66
+ * Process can also be called manually (via 'process').
67
+ */
68
+ Backbone.BlockingQueue = function() {
69
+ this._queue = [];
70
+ };
71
+ _.extend( Backbone.BlockingQueue.prototype, Backbone.Semaphore, {
72
+ _queue: null,
73
+
74
+ add: function( func ) {
75
+ if ( this.isBlocked() ) {
76
+ this._queue.push( func );
77
+ }
78
+ else {
79
+ func();
80
+ }
81
+ },
82
+
83
+ process: function() {
84
+ while ( this._queue && this._queue.length ) {
85
+ this._queue.shift()();
86
+ }
87
+ },
88
+
89
+ block: function() {
90
+ this.acquire();
91
+ },
92
+
93
+ unblock: function() {
94
+ this.release();
95
+ if ( !this.isBlocked() ) {
96
+ this.process();
97
+ }
98
+ },
99
+
100
+ isBlocked: function() {
101
+ return this.isLocked();
102
+ }
103
+ });
104
+ /**
105
+ * Global event queue. Accumulates external events ('add:<key>', 'remove:<key>' and 'update:<key>')
106
+ * until the top-level object is fully initialized (see 'Backbone.RelationalModel').
107
+ */
108
+ Backbone.Relational.eventQueue = new Backbone.BlockingQueue();
109
+
110
+ /**
111
+ * Backbone.Store keeps track of all created (and destruction of) Backbone.RelationalModel.
112
+ * Handles lookup for relations.
113
+ */
114
+ Backbone.Store = function() {
115
+ this._collections = [];
116
+ this._reverseRelations = [];
117
+ };
118
+ _.extend( Backbone.Store.prototype, Backbone.Events, {
119
+ _collections: null,
120
+ _reverseRelations: null,
121
+
122
+ /**
123
+ * Add a reverse relation. Is added to the 'relations' property on model's prototype, and to
124
+ * existing instances of 'model' in the store as well.
125
+ * @param {object} relation; required properties:
126
+ * - model, type, key and relatedModel
127
+ */
128
+ addReverseRelation: function( relation ) {
129
+ var exists = _.any( this._reverseRelations, function( rel ) {
130
+ return _.all( relation, function( val, key ) {
131
+ return val === rel[ key ];
132
+ });
133
+ });
134
+
135
+ if ( !exists && relation.model && relation.type ) {
136
+ this._reverseRelations.push( relation );
137
+
138
+ if ( !relation.model.prototype.relations ) {
139
+ relation.model.prototype.relations = [];
140
+ }
141
+ relation.model.prototype.relations.push( relation );
142
+
143
+ this.retroFitRelation( relation );
144
+ }
145
+ },
146
+
147
+ /**
148
+ * Add a 'relation' to all existing instances of 'relation.model' in the store
149
+ */
150
+ retroFitRelation: function( relation ) {
151
+ var coll = this.getCollection( relation.model );
152
+ coll.each( function( model ) {
153
+ var rel = new relation.type( model, relation );
154
+ }, this);
155
+ },
156
+
157
+ /**
158
+ * Find the Store's collection for a certain type of model.
159
+ * @param model {Backbone.RelationalModel}
160
+ * @return {Backbone.Collection} A collection if found (or applicable for 'model'), or null
161
+ */
162
+ getCollection: function( model ) {
163
+ var coll = _.detect( this._collections, function( c ) {
164
+ // Check if model is the type itself (a ref to the constructor), or is of type c.model
165
+ return model === c.model || model instanceof c.model;
166
+ });
167
+
168
+ if ( !coll ) {
169
+ coll = this._createCollection( model );
170
+ }
171
+
172
+ return coll;
173
+ },
174
+
175
+ /**
176
+ * Find a type on the global object by name. Splits name on dots.
177
+ * @param {string} name
178
+ */
179
+ getObjectByName: function( name ) {
180
+ var type = _.reduce( name.split('.'), function( memo, val ) {
181
+ return memo[ val ];
182
+ }, exports);
183
+ return type !== exports ? type: null;
184
+ },
185
+
186
+ _createCollection: function( type ) {
187
+ var coll;
188
+
189
+ // If 'type' is an instance, take it's constructor
190
+ if ( type instanceof Backbone.RelationalModel ) {
191
+ type = type.constructor;
192
+ }
193
+
194
+ // Type should inherit from Backbone.RelationalModel.
195
+ if ( type.prototype instanceof Backbone.RelationalModel.prototype.constructor ) {
196
+ coll = new Backbone.Collection();
197
+ coll.model = type;
198
+
199
+ this._collections.push( coll );
200
+ }
201
+
202
+ return coll;
203
+ },
204
+
205
+ find: function( type, id ) {
206
+ var coll = this.getCollection( type );
207
+ return coll && coll.get( id );
208
+ },
209
+
210
+ /**
211
+ * Add a 'model' to it's appropriate collection. Retain the original contents of 'model.collection'.
212
+ */
213
+ register: function( model ) {
214
+ var modelColl = model.collection;
215
+ var coll = this.getCollection( model );
216
+ coll && coll._add( model );
217
+ model.collection = modelColl;
218
+ },
219
+
220
+ /**
221
+ * Explicitly update a model's id in it's store collection
222
+ */
223
+ update: function( model ) {
224
+ var coll = this.getCollection( model );
225
+ coll._onModelEvent( 'change:' + model.idAttribute, model, coll );
226
+ },
227
+
228
+ /**
229
+ * Remove a 'model' from the store.
230
+ */
231
+ unregister: function( model ) {
232
+ var coll = this.getCollection( model );
233
+ coll && coll.remove( model );
234
+ }
235
+ });
236
+ Backbone.Relational.store = new Backbone.Store();
237
+
238
+ /**
239
+ * The main Relation class, from which 'HasOne' and 'HasMany' inherit.
240
+ * @param {Backbone.RelationalModel} instance
241
+ * @param {object} options.
242
+ * Required properties:
243
+ * - {string} key
244
+ * - {Backbone.RelationalModel.constructor} relatedModel
245
+ * Optional properties:
246
+ * - {bool} includeInJSON: create objects from the contents of keys if the object is not found in Backbone.store.
247
+ * - {bool} createModels: serialize the attributes for related model(s)' in toJSON on create/update, or just their ids.
248
+ * - {object} reverseRelation: Specify a bi-directional relation. If provided, Relation will reciprocate
249
+ * the relation to the 'relatedModel'. Required and optional properties match 'options', except for:
250
+ * - {Backbone.Relation|string} type: 'HasOne' or 'HasMany'
251
+ */
252
+ Backbone.Relation = function( instance, options ) {
253
+ this.instance = instance;
254
+
255
+ // Make sure 'options' is sane, and fill with defaults from subclasses and this object's prototype
256
+ options = ( typeof options === 'object' && options ) || {};
257
+ this.reverseRelation = _.defaults( options.reverseRelation || {}, this.options.reverseRelation );
258
+ this.reverseRelation.type = !_.isString( this.reverseRelation.type ) ? this.reverseRelation.type :
259
+ Backbone[ this.reverseRelation.type ] || Backbone.Relational.store.getObjectByName( this.reverseRelation.type );
260
+ this.options = _.defaults( options, this.options, Backbone.Relation.prototype.options );
261
+
262
+ this.key = this.options.key;
263
+ this.keyContents = this.instance.get( this.key );
264
+
265
+ // 'exports' should be the global object where 'relatedModel' can be found on if given as a string.
266
+ this.relatedModel = this.options.relatedModel;
267
+ if ( _.isString( this.relatedModel ) ) {
268
+ this.relatedModel = Backbone.Relational.store.getObjectByName( this.relatedModel );
269
+ }
270
+
271
+ if ( !this.checkPreconditions() ) {
272
+ return false;
273
+ }
274
+
275
+ // Add this Relation to instance._relations
276
+ this.instance._relations.push( this );
277
+
278
+ // Add the reverse relation on 'relatedModel' to the store's reverseRelations
279
+ if ( !this.options.isAutoRelation && this.reverseRelation.type && this.reverseRelation.key ) {
280
+ Backbone.Relational.store.addReverseRelation( _.defaults( {
281
+ isAutoRelation: true,
282
+ model: this.relatedModel,
283
+ relatedModel: this.instance.constructor,
284
+ reverseRelation: this.options // current relation is the 'reverseRelation' for it's own reverseRelation
285
+ },
286
+ this.reverseRelation // Take further properties from this.reverseRelation (type, key, etc.)
287
+ ) );
288
+ }
289
+
290
+ this.initialize();
291
+
292
+ _.bindAll( this, '_modelRemovedFromCollection', '_relatedModelAdded', '_relatedModelRemoved' );
293
+ // When a model in the store is destroyed, check if it is 'this.instance'.
294
+ Backbone.Relational.store.getCollection( this.instance )
295
+ .bind( 'relational:remove', this._modelRemovedFromCollection );
296
+
297
+ // When 'relatedModel' are created or destroyed, check if it affects this relation.
298
+ Backbone.Relational.store.getCollection( this.relatedModel )
299
+ .bind( 'relational:add', this._relatedModelAdded )
300
+ .bind( 'relational:remove', this._relatedModelRemoved );
301
+ };
302
+ // Fix inheritance :\
303
+ Backbone.Relation.extend = Backbone.Model.extend;
304
+ // Set up all inheritable **Backbone.Relation** properties and methods.
305
+ _.extend( Backbone.Relation.prototype, Backbone.Events, Backbone.Semaphore, {
306
+ options: {
307
+ createModels: true,
308
+ includeInJSON: true,
309
+ isAutoRelation: false
310
+ },
311
+
312
+ instance: null,
313
+ key: null,
314
+ keyContents: null,
315
+ relatedModel: null,
316
+ reverseRelation: null,
317
+ related: null,
318
+
319
+ _relatedModelAdded: function( model, coll, options ) {
320
+ // Allow 'model' to set up it's relations, before calling 'tryAddRelated'
321
+ // (which can result in a call to 'addRelated' on a relation of 'model')
322
+ var dit = this;
323
+ model.queue( function() {
324
+ dit.tryAddRelated( model, options );
325
+ });
326
+ },
327
+
328
+ _relatedModelRemoved: function( model, coll, options ) {
329
+ this.removeRelated( model, options );
330
+ },
331
+
332
+ _modelRemovedFromCollection: function( model ) {
333
+ if ( model === this.instance ) {
334
+ this.destroy();
335
+ }
336
+ },
337
+
338
+ /**
339
+ * Check several pre-conditions.
340
+ * @return {bool} True if pre-conditions are satisfied, false if they're not.
341
+ */
342
+ checkPreconditions: function() {
343
+ var i = this.instance, k = this.key, rm = this.relatedModel;
344
+ if ( !i || !k || !rm ) {
345
+ console && console.warn( 'Relation=%o; no instance, key or relatedModel (%o, %o, %o)', this, i, k, rm );
346
+ return false;
347
+ }
348
+ // Check if 'instance' is a Backbone.RelationalModel
349
+ if ( !( i instanceof Backbone.RelationalModel ) ) {
350
+ console && console.warn( 'Relation=%o; instance=%o is not a Backbone.RelationalModel', this, i );
351
+ return false;
352
+ }
353
+ // Check if the type in 'relatedModel' inherits from Backbone.RelationalModel
354
+ if ( !( rm.prototype instanceof Backbone.RelationalModel.prototype.constructor ) ) {
355
+ console && console.warn( 'Relation=%o; relatedModel does not inherit from Backbone.RelationalModel (%o)', this, rm );
356
+ return false;
357
+ }
358
+ // Check if this is not a HasMany, and the reverse relation is HasMany as well
359
+ if ( this instanceof Backbone.HasMany && this.reverseRelation.type === Backbone.HasMany.prototype.constructor ) {
360
+ console && console.warn( 'Relation=%o; relation is a HasMany, and the reverseRelation is HasMany as well.', this );
361
+ return false;
362
+ }
363
+ // Check if we're not attempting to create a duplicate relationship
364
+ if ( i._relations.length ) {
365
+ var exists = _.any( i._relations, function( rel ) {
366
+ var hasReverseRelation = this.reverseRelation.key && rel.reverseRelation.key;
367
+ return rel.relatedModel === rm && rel.key === k
368
+ && ( !hasReverseRelation || this.reverseRelation.key === rel.reverseRelation.key );
369
+ }, this );
370
+
371
+ if ( exists ) {
372
+ console && console.warn( 'Relation=%o between instance=%o.%s and relatedModel=%o.%s already exists',
373
+ this, i, k, rm, this.reverseRelation.key );
374
+ return false;
375
+ }
376
+ }
377
+ return true;
378
+ },
379
+
380
+ setRelated: function( related, options ) {
381
+ this.related = related;
382
+ var value = {};
383
+ value[ this.key ] = related;
384
+ this.instance.acquire();
385
+ this.instance.set( value, _.defaults( options || {}, { silent: true } ) );
386
+ this.instance.release();
387
+ },
388
+
389
+ createModel: function( item ) {
390
+ if ( this.options.createModels && typeof( item ) === 'object' ) {
391
+ return new this.relatedModel( item );
392
+ }
393
+ },
394
+
395
+ /**
396
+ * Determine if a relation (on a different RelationalModel) is the reverse
397
+ * relation of the current one.
398
+ */
399
+ _isReverseRelation: function( relation ) {
400
+ if ( relation.instance instanceof this.relatedModel && this.reverseRelation.key === relation.key
401
+ && this.key === relation.reverseRelation.key ) {
402
+ return true;
403
+ }
404
+ return false;
405
+ },
406
+
407
+ /**
408
+ * Get the reverse relations (pointing back to 'this.key' on 'this.instance') for the currently related model(s).
409
+ * @param model {Backbone.RelationalModel} Optional; get the reverse relations for a specific model.
410
+ * If not specified, 'this.related' is used.
411
+ */
412
+ getReverseRelations: function( model ) {
413
+ var reverseRelations = [];
414
+ // Iterate over 'model', 'this.related.models' (if this.related is a Backbone.Collection), or wrap 'this.related' in an array.
415
+ var models = !_.isUndefined( model ) ? [ model ] : this.related && ( this.related.models || [ this.related ] );
416
+ _.each( models , function( related ) {
417
+ _.each( related.getRelations(), function( relation ) {
418
+ if ( this._isReverseRelation( relation ) ) {
419
+ reverseRelations.push( relation );
420
+ }
421
+ }, this );
422
+ }, this );
423
+
424
+ return reverseRelations;
425
+ },
426
+
427
+ /**
428
+ * Rename options.silent, so add/remove events propagate properly.
429
+ * (for example in HasMany, from 'addRelated'->'handleAddition')
430
+ */
431
+ sanitizeOptions: function( options ) {
432
+ options || ( options = {} );
433
+ if ( options.silent ) {
434
+ options = _.extend( {}, options, { silentChange: true } );
435
+ delete options.silent;
436
+ }
437
+ return options;
438
+ },
439
+
440
+ // Cleanup. Get reverse relation, call removeRelated on each.
441
+ destroy: function() {
442
+ Backbone.Relational.store.getCollection( this.instance )
443
+ .unbind( 'relational:remove', this._modelRemovedFromCollection );
444
+
445
+ Backbone.Relational.store.getCollection( this.relatedModel )
446
+ .unbind( 'relational:add', this._relatedModelAdded )
447
+ .unbind( 'relational:remove', this._relatedModelRemoved );
448
+
449
+ _.each( this.getReverseRelations(), function( relation ) {
450
+ relation.removeRelated( this.instance );
451
+ }, this );
452
+ }
453
+ });
454
+
455
+ Backbone.HasOne = Backbone.Relation.extend({
456
+ options: {
457
+ reverseRelation: { type: 'HasMany' }
458
+ },
459
+
460
+ initialize: function() {
461
+ _.bindAll( this, 'onChange' );
462
+ this.instance.bind( 'relational:change:' + this.key, this.onChange );
463
+
464
+ var model = this.findRelated();
465
+ this.setRelated( model );
466
+
467
+ // Notify new 'related' object of the new relation.
468
+ var dit = this;
469
+ _.each( dit.getReverseRelations(), function( relation ) {
470
+ relation.addRelated( dit.instance );
471
+ } );
472
+ },
473
+
474
+ findRelated: function() {
475
+ var item = this.keyContents;
476
+ var model = null;
477
+
478
+ if ( item instanceof this.relatedModel ) {
479
+ model = item;
480
+ }
481
+ else if ( item && ( _.isString( item ) || _.isNumber( item ) || typeof( item ) === 'object' ) ) {
482
+ // Try to find an instance of the appropriate 'relatedModel' in the store, or create it
483
+ var id = _.isString( item ) || _.isNumber( item ) ? item : item[ this.relatedModel.prototype.idAttribute ];
484
+ model = Backbone.Relational.store.find( this.relatedModel, id ) || this.createModel( item );
485
+ }
486
+
487
+ return model;
488
+ },
489
+
490
+ /**
491
+ * If the key is changed, notify old & new reverse relations and initialize the new relation
492
+ */
493
+ onChange: function( model, attr, options ) {
494
+ // Don't accept recursive calls to onChange (like onChange->findRelated->createModel->initializeRelations->addRelated->onChange)
495
+ if ( this.isLocked() ) {
496
+ return;
497
+ }
498
+ this.acquire();
499
+ options = this.sanitizeOptions( options );
500
+
501
+ // 'options._related' is set by 'addRelated'/'removeRelated'. If it is set, the change
502
+ // is the result of a call from a relation. If it's not, the change is the result of
503
+ // a 'set' call on this.instance.
504
+ var changed = _.isUndefined( options._related );
505
+ var oldRelated = changed ? this.related : options._related;
506
+
507
+ if ( changed ) {
508
+ this.keyContents = attr;
509
+
510
+ // Set new 'related'
511
+ if ( attr instanceof this.relatedModel ) {
512
+ this.related = attr;
513
+ }
514
+ else if ( attr ) {
515
+ var related = this.findRelated();
516
+ this.setRelated( related );
517
+ }
518
+ else {
519
+ this.setRelated( null );
520
+ }
521
+ }
522
+
523
+ // Notify old 'related' object of the terminated relation
524
+ if ( oldRelated && this.related !== oldRelated ) {
525
+ _.each( this.getReverseRelations( oldRelated ), function( relation ) {
526
+ relation.removeRelated( this.instance, options );
527
+ }, this );
528
+ }
529
+
530
+ // Notify new 'related' object of the new relation. Note we do re-apply even if this.related is oldRelated;
531
+ // that can be necessary for bi-directional relations if 'this.instance' was created after 'this.related'.
532
+ // In that case, 'this.instance' will already know 'this.related', but the reverse might not exist yet.
533
+ _.each( this.getReverseRelations(), function( relation ) {
534
+ relation.addRelated( this.instance, options );
535
+ }, this);
536
+
537
+ // Fire the 'update:<key>' event if 'related' was updated
538
+ if ( !options.silentChange && this.related !== oldRelated ) {
539
+ var dit = this;
540
+ Backbone.Relational.eventQueue.add( function() {
541
+ dit.instance.trigger( 'update:' + dit.key, dit.instance, dit.related, options );
542
+ });
543
+ }
544
+ this.release();
545
+ },
546
+
547
+ /**
548
+ * If a new 'this.relatedModel' appears in the 'store', try to match it to the last set 'keyContents'
549
+ */
550
+ tryAddRelated: function( model, options ) {
551
+ if ( this.related ) {
552
+ return;
553
+ }
554
+ options = this.sanitizeOptions( options );
555
+
556
+ var item = this.keyContents;
557
+ if ( item && ( _.isString( item ) || _.isNumber( item ) || typeof( item ) === 'object' ) ) {
558
+ var id = _.isString( item ) || _.isNumber( item ) ? item : item[ this.relatedModel.prototype.idAttribute ];
559
+ if ( model.id === id ) {
560
+ this.addRelated( model, options );
561
+ }
562
+ }
563
+ },
564
+
565
+ addRelated: function( model, options ) {
566
+ if ( model !== this.related ) {
567
+ var oldRelated = this.related || null;
568
+ this.setRelated( model );
569
+ this.onChange( this.instance, model, { _related: oldRelated } );
570
+ }
571
+ },
572
+
573
+ removeRelated: function( model, options ) {
574
+ if ( !this.related ) {
575
+ return;
576
+ }
577
+
578
+ if ( model === this.related ) {
579
+ var oldRelated = this.related || null;
580
+ this.setRelated( null );
581
+ this.onChange( this.instance, model, { _related: oldRelated } );
582
+ }
583
+ }
584
+ });
585
+
586
+ Backbone.HasMany = Backbone.Relation.extend({
587
+ collectionType: null,
588
+
589
+ options: {
590
+ reverseRelation: { type: 'HasOne' },
591
+ collectionType: Backbone.Collection
592
+ },
593
+
594
+ initialize: function() {
595
+ _.bindAll( this, 'onChange', 'handleAddition', 'handleRemoval' );
596
+ this.instance.bind( 'relational:change:' + this.key, this.onChange );
597
+
598
+ // Handle a custom 'collectionType'
599
+ this.collectionType = this.options.collectionType;
600
+ if ( _( this.collectionType ).isString() ) {
601
+ this.collectionType = Backbone.Relational.store.getObjectByName( this.collectionType );
602
+ }
603
+ if ( !this.collectionType.prototype instanceof Backbone.Collection.prototype.constructor ){
604
+ throw new Error( 'collectionType must inherit from Backbone.Collection' );
605
+ }
606
+
607
+ this.setRelated( this.prepareCollection( new this.collectionType() ) );
608
+ this.findRelated();
609
+ },
610
+
611
+ prepareCollection: function( collection ) {
612
+ if ( this.related ) {
613
+ this.related.unbind( 'relational:add', this.handleAddition ).unbind('relational:remove', this.handleRemoval );
614
+ }
615
+
616
+ collection.reset();
617
+ collection.model = this.relatedModel;
618
+ collection.bind( 'relational:add', this.handleAddition ).bind('relational:remove', this.handleRemoval );
619
+ return collection;
620
+ },
621
+
622
+ findRelated: function() {
623
+ if ( this.keyContents && _.isArray( this.keyContents ) ) {
624
+ // Try to find instances of the appropriate 'relatedModel' in the store
625
+ _.each( this.keyContents, function( item ) {
626
+ var id = _.isString( item ) || _.isNumber( item ) ? item : item[ this.relatedModel.prototype.idAttribute ];
627
+ var model = Backbone.Relational.store.find( this.relatedModel, id ) || this.createModel( item );
628
+
629
+ if ( model && !this.related.getByCid( model ) && !this.related.get( model ) ) {
630
+ this.related._add( model );
631
+ }
632
+ }, this);
633
+ }
634
+ },
635
+
636
+ /**
637
+ * If the key is changed, notify old & new reverse relations and initialize the new relation
638
+ */
639
+ onChange: function( model, attr, options ) {
640
+ options = this.sanitizeOptions( options );
641
+ this.keyContents = attr;
642
+
643
+ // Notify old 'related' object of the terminated relation
644
+ _.each( this.getReverseRelations(), function( relation ) {
645
+ relation.removeRelated( this.instance, options );
646
+ }, this );
647
+
648
+ // Replace 'this.related' by 'attr' if it is a Backbone.Collection
649
+ if ( attr instanceof Backbone.Collection ) {
650
+ this.prepareCollection( attr );
651
+ this.related = attr;
652
+ }
653
+ // Otherwise, 'attr' should be an array of related object ids
654
+ else {
655
+ // Re-use the current 'this.related' if it is a Backbone.Collection
656
+ var coll = this.related instanceof Backbone.Collection ? this.related : new this.collectionType();
657
+ this.setRelated( this.prepareCollection( coll ) );
658
+ this.findRelated();
659
+ }
660
+
661
+ // Notify new 'related' object of the new relation
662
+ _.each( this.getReverseRelations(), function( relation ) {
663
+ relation.addRelated( this.instance, options );
664
+ }, this );
665
+
666
+ var dit = this;
667
+ Backbone.Relational.eventQueue.add( function() {
668
+ !options.silentChange && dit.instance.trigger( 'update:' + dit.key, dit.instance, dit.related, options );
669
+ });
670
+ },
671
+
672
+ tryAddRelated: function( model, options ) {
673
+ options = this.sanitizeOptions( options );
674
+ if ( !this.related.getByCid( model ) && !this.related.get( model ) ) {
675
+ // Check if this new model was specified in 'this.keyContents'
676
+ var item = _.any( this.keyContents, function( item ) {
677
+ var id = _.isString( item ) || _.isNumber( item ) ? item : item[ this.relatedModel.prototype.idAttribute ];
678
+ return id && id === model.id;
679
+ }, this );
680
+
681
+ if ( item ) {
682
+ this.related._add( model, options );
683
+ }
684
+ }
685
+ },
686
+
687
+ /**
688
+ * When a model is added to a 'HasMany', trigger 'add' on 'this.instance' and notify reverse relations.
689
+ * (should be 'HasOne', must set 'this.instance' as their related).
690
+ */
691
+ handleAddition: function( model, coll, options ) {
692
+ //console.debug('handleAddition called; args=%o', arguments);
693
+ options = this.sanitizeOptions( options );
694
+ var dit = this;
695
+
696
+ _.each( this.getReverseRelations( model ), function( relation ) {
697
+ relation.addRelated( this.instance, options );
698
+ }, this );
699
+
700
+ // Only trigger 'add' once the newly added model is initialized (so, has it's relations set up)
701
+ Backbone.Relational.eventQueue.add( function() {
702
+ !options.silentChange && dit.instance.trigger( 'add:' + dit.key, model, dit.related, options );
703
+ });
704
+ },
705
+
706
+ /**
707
+ * When a model is removed from a 'HasMany', trigger 'remove' on 'this.instance' and notify reverse relations.
708
+ * (should be 'HasOne', which should be nullified)
709
+ */
710
+ handleRemoval: function( model, coll, options ) {
711
+ //console.debug('handleRemoval called; args=%o', arguments);
712
+ options = this.sanitizeOptions( options );
713
+
714
+ _.each( this.getReverseRelations( model ), function( relation ) {
715
+ relation.removeRelated( this.instance, options );
716
+ }, this );
717
+
718
+ var dit = this;
719
+ Backbone.Relational.eventQueue.add( function() {
720
+ !options.silentChange && dit.instance.trigger( 'remove:' + dit.key, model, dit.related, options );
721
+ });
722
+ },
723
+
724
+ addRelated: function( model, options ) {
725
+ var dit = this;
726
+ model.queue( function() { // Queued to avoid errors for adding 'model' to the 'this.related' set twice
727
+ if ( dit.related && !dit.related.getByCid( model ) && !dit.related.get( model ) ) {
728
+ dit.related._add( model, options );
729
+ }
730
+ });
731
+ },
732
+
733
+ removeRelated: function( model, options ) {
734
+ if ( this.related.getByCid( model ) || this.related.get( model ) ) {
735
+ this.related.remove( model, options );
736
+ }
737
+ }
738
+ });
739
+
740
+ /**
741
+ * New events:
742
+ * - 'add:<key>' (model, related collection, options)
743
+ * - 'remove:<key>' (model, related collection, options)
744
+ * - 'update:<key>' (model, related model or collection, options)
745
+ */
746
+ Backbone.RelationalModel = Backbone.Model.extend({
747
+ relations: null, // Relation descriptions on the prototype
748
+ _relations: null, // Relation instances
749
+ _isInitialized: false,
750
+ _deferProcessing: false,
751
+ _queue: null,
752
+
753
+ constructor: function( attributes, options ) {
754
+ // Nasty hack, for cases like 'model.get( <HasMany key> ).add( item )'.
755
+ // Defer 'processQueue', so that when 'Relation.createModels' is used we:
756
+ // a) Survive 'Backbone.Collection._add'; this takes care we won't error on "can't add model to a set twice"
757
+ // (by creating a model from properties, having the model add itself to the collection via one of
758
+ // it's relations, then trying to add it to the collection).
759
+ // b) Trigger 'HasMany' collection events only after the model is really fully set up.
760
+ // Example that triggers both a and b: "p.get('jobs').add( { company: c, person: p } )".
761
+ var dit = this;
762
+ if ( options && options.collection ) {
763
+ this._deferProcessing = true;
764
+
765
+ var processQueue = function( model, coll ) {
766
+ if ( model === dit ) {
767
+ dit._deferProcessing = false;
768
+ dit.processQueue();
769
+ options.collection.unbind( 'relational:add', processQueue );
770
+ }
771
+ };
772
+ options.collection.bind( 'relational:add', processQueue );
773
+
774
+ // So we do process the queue eventually, regardless of whether this model really gets added to 'options.collection'.
775
+ _.defer( function() {
776
+ processQueue( dit );
777
+ });
778
+ }
779
+
780
+ this._queue = new Backbone.BlockingQueue();
781
+ this._queue.block();
782
+ Backbone.Relational.eventQueue.block();
783
+
784
+ Backbone.Model.prototype.constructor.apply( this, arguments );
785
+
786
+ // Try to run the global queue holding external events
787
+ Backbone.Relational.eventQueue.unblock();
788
+ },
789
+
790
+ /**
791
+ * Override 'trigger' to queue 'change' and 'change:*' events
792
+ */
793
+ trigger: function( eventName ) {
794
+ if ( eventName.length > 5 && 'change' === eventName.substr( 0, 6 ) ) {
795
+ var dit = this, args = arguments;
796
+ Backbone.Relational.eventQueue.add( function() {
797
+ Backbone.Model.prototype.trigger.apply( dit, args );
798
+ });
799
+ }
800
+ else {
801
+ Backbone.Model.prototype.trigger.apply( this, arguments );
802
+ }
803
+
804
+ return this;
805
+ },
806
+
807
+ /**
808
+ * Initialize Relations present in this.relations; determine the type (HasOne/HasMany), then create a new instance.
809
+ * The regular constructor (which fills this.attributes, initialize, etc) hasn't run yet at this time!
810
+ */
811
+ initializeRelations: function() {
812
+ this.acquire(); // Setting up relations often also involve calls to 'set', and we only want to enter this function once
813
+ this._relations = [];
814
+
815
+ _.each( this.relations, function( rel ) {
816
+ var type = !_.isString( rel.type ) ? rel.type : Backbone[ rel.type ] || Backbone.Relational.store.getObjectByName( rel.type );
817
+ if ( type && type.prototype instanceof Backbone.Relation.prototype.constructor ) {
818
+ new type( this, rel ); // Also pushes the new Relation into _relations
819
+ }
820
+ else {
821
+ console && console.warn( 'Relation=%o; missing or invalid type!', rel );
822
+ }
823
+ }, this );
824
+
825
+ this._isInitialized = true;
826
+ this.release();
827
+ this.processQueue();
828
+ },
829
+
830
+ /**
831
+ * When new values are set, notify this model's relations (also if options.silent is set).
832
+ * (Relation.setRelated locks this model before calling 'set' on it to prevent loops)
833
+ */
834
+ updateRelations: function( options ) {
835
+ if( this._isInitialized && !this.isLocked() ) {
836
+ _.each( this._relations, function( rel ) {
837
+ var val = this.attributes[ rel.key ];
838
+ if ( rel.related !== val ) {
839
+ this.trigger('relational:change:' + rel.key, this, val, options || {} );
840
+ }
841
+ }, this );
842
+ }
843
+ },
844
+
845
+ /**
846
+ * Either add to the queue (if we're not initialized yet), or execute right away.
847
+ */
848
+ queue: function( func ) {
849
+ this._queue.add( func );
850
+ },
851
+
852
+ /**
853
+ * Process _queue
854
+ */
855
+ processQueue: function() {
856
+ if ( this._isInitialized && !this._deferProcessing && this._queue.isBlocked() ) {
857
+ this._queue.unblock();
858
+ }
859
+ },
860
+
861
+ /**
862
+ * Get a specific relation.
863
+ * @param key {string} The relation key to look for.
864
+ * @return {Backbone.Relation|null} An instance of 'Backbone.Relation', if a relation was found for 'key', or null.
865
+ */
866
+ getRelation: function( key ) {
867
+ return _.detect( this._relations, function( rel ) {
868
+ if ( rel.key === key ) {
869
+ return true;
870
+ }
871
+ }, this );
872
+ },
873
+
874
+ /**
875
+ * Get all of the created relations.
876
+ * @return {array of Backbone.Relation}
877
+ */
878
+ getRelations: function() {
879
+ return this._relations;
880
+ },
881
+
882
+ /**
883
+ * Retrieve related objects.
884
+ * @param key {string} The relation key to fetch models for.
885
+ * @param options {object} Options for 'Backbone.Model.fetch' and 'Backbone.sync'.
886
+ * @return {xhr} An array or request objects
887
+ */
888
+ fetchRelated: function( key, options ) {
889
+ options || ( options = {} );
890
+ var rel = this.getRelation( key ),
891
+ keyContents = rel && rel.keyContents,
892
+ toFetch = keyContents && _.select( _.isArray( keyContents ) ? keyContents : [ keyContents ], function( item ) {
893
+ var id = _.isString( item ) || _.isNumber( item ) ? item : item[ rel.relatedModel.prototype.idAttribute ];
894
+ return id && !Backbone.Relational.store.find( rel.relatedModel, id );
895
+ }, this );
896
+
897
+ if ( toFetch && toFetch.length ) {
898
+ // Create a model for each entry in 'keyContents' that is to be fetched
899
+ var models = _.map( toFetch, function( item ) {
900
+ if ( typeof( item ) === 'object' ) {
901
+ var model = new rel.relatedModel( item );
902
+ }
903
+ else {
904
+ var attrs = {};
905
+ attrs[ rel.relatedModel.prototype.idAttribute ] = item;
906
+ var model = new rel.relatedModel( attrs );
907
+ }
908
+ return model;
909
+ }, this );
910
+
911
+ // Try if the 'collection' can provide a url to fetch a set of models in one request.
912
+ if ( rel.related instanceof Backbone.Collection && _.isFunction( rel.related.url ) ) {
913
+ var setUrl = rel.related.url( models );
914
+ }
915
+
916
+ // An assumption is that when 'Backbone.Collection.url' is a function, it can handle building of set urls.
917
+ // To make sure it can, test if the url we got by supplying a list of models to fetch is different from
918
+ // the one supplied for the default fetch action (without args to 'url').
919
+ if ( setUrl && setUrl !== rel.related.url() ) {
920
+ var opts = _.defaults( {
921
+ error: function() {
922
+ var args = arguments;
923
+ _.each( models, function( model ) {
924
+ model.destroy();
925
+ options.error && options.error.apply( model, args );
926
+ })
927
+ },
928
+ url: setUrl
929
+ },
930
+ options,
931
+ { add: true }
932
+ );
933
+
934
+ var requests = [ rel.related.fetch( opts ) ];
935
+ }
936
+ else {
937
+ var requests = _.map( models, function( model ) {
938
+ var opts = _.defaults( {
939
+ error: function() {
940
+ model.destroy();
941
+ options.error && options.error.apply( model, arguments );
942
+ }
943
+ },
944
+ options
945
+ );
946
+ return model.fetch( opts );
947
+ }, this );
948
+ }
949
+ }
950
+
951
+ return _.isUndefined( requests ) ? [] : requests;
952
+ },
953
+
954
+ set: function( attributes, options ) {
955
+ Backbone.Relational.eventQueue.block();
956
+
957
+ var result = Backbone.Model.prototype.set.apply( this, arguments );
958
+
959
+ // 'set' is called quite late in 'Backbone.Model.prototype.constructor', but before 'initialize'.
960
+ // Ideal place to set up relations :)
961
+ if ( !this._isInitialized && !this.isLocked() ) {
962
+ Backbone.Relational.store.register( this );
963
+ this.initializeRelations();
964
+ }
965
+ // Update the 'idAttribute' in Backbone.store if; we don't want it to miss an 'id' update due to {silent:true}
966
+ else if ( attributes && this.idAttribute in attributes ) {
967
+ Backbone.Relational.store.update( this );
968
+ }
969
+
970
+ this.updateRelations( options );
971
+
972
+ // Try to run the global queue holding external events
973
+ Backbone.Relational.eventQueue.unblock();
974
+
975
+ return result;
976
+ },
977
+
978
+ unset: function( attributes, options ) {
979
+ Backbone.Relational.eventQueue.block();
980
+
981
+ var result = Backbone.Model.prototype.unset.apply( this, arguments );
982
+ this.updateRelations( options );
983
+
984
+ // Try to run the global queue holding external events
985
+ Backbone.Relational.eventQueue.unblock();
986
+
987
+ return result;
988
+ },
989
+
990
+ clear: function( options ) {
991
+ Backbone.Relational.eventQueue.block();
992
+
993
+ var result = Backbone.Model.prototype.clear.apply( this, arguments );
994
+ this.updateRelations( options );
995
+
996
+ // Try to run the global queue holding external events
997
+ Backbone.Relational.eventQueue.unblock();
998
+
999
+ return result;
1000
+ },
1001
+
1002
+ /**
1003
+ * Override 'change', so the change will only execute after 'set' has finised (relations are updated),
1004
+ * and 'previousAttributes' will be available when the event is fired.
1005
+ */
1006
+ change: function( options ) {
1007
+ var dit = this;
1008
+ Backbone.Relational.eventQueue.add( function() {
1009
+ Backbone.Model.prototype.change.apply( dit, arguments );
1010
+ });
1011
+ },
1012
+
1013
+ destroy: function( options ) {
1014
+ Backbone.Relational.store.unregister( this );
1015
+ return Backbone.Model.prototype.destroy.call( this, options );
1016
+ },
1017
+
1018
+ /**
1019
+ * Convert relations to JSON, omits them when required
1020
+ */
1021
+ toJSON: function() {
1022
+ // If this Model has already been fully serialized in this branch once, return to avoid loops
1023
+ if ( this.isLocked() ) {
1024
+ return this.id;
1025
+ }
1026
+
1027
+ var json = Backbone.Model.prototype.toJSON.call( this );
1028
+
1029
+ _.each( this._relations, function( rel ) {
1030
+ var value = json[ rel.key ];
1031
+
1032
+ if ( rel.options.includeInJSON === true && value && _.isFunction( value.toJSON ) ) {
1033
+ this.acquire();
1034
+ json[ rel.key ] = value.toJSON();
1035
+ this.release();
1036
+ }
1037
+ else if ( _.isString( rel.options.includeInJSON ) ) {
1038
+ if ( value instanceof Backbone.Collection ) {
1039
+ json[ rel.key ] = value.pluck( rel.options.includeInJSON );
1040
+ }
1041
+ else if ( value instanceof Backbone.Model ) {
1042
+ json[ rel.key ] = value.get( rel.options.includeInJSON );
1043
+ }
1044
+ }
1045
+ else {
1046
+ delete json[ rel.key ];
1047
+ }
1048
+ }, this );
1049
+
1050
+ return json;
1051
+ }
1052
+ });
1053
+ _.extend( Backbone.RelationalModel.prototype, Backbone.Semaphore );
1054
+
1055
+ /**
1056
+ * Override Backbone.Collection._add, so objects fetched from the server multiple times will
1057
+ * update the existing Model. Also, trigger 'relation:add'.
1058
+ */
1059
+ var _add = Backbone.Collection.prototype._add;
1060
+ Backbone.Collection.prototype._add = function( model, options ) {
1061
+ if ( !( model instanceof Backbone.Model ) ) {
1062
+ // Try to find 'model' in Backbone.store. If it already exists, set the new properties on it.
1063
+ var found = Backbone.Relational.store.find( this.model, model[ this.model.prototype.idAttribute ] );
1064
+ if ( found ) {
1065
+ model = found.set( model, options );
1066
+ }
1067
+ }
1068
+
1069
+ //console.debug( 'calling _add on coll=%o; model=%s (%o), options=%o', this, model.cid, model, options );
1070
+ if ( !( model instanceof Backbone.Model ) || !( this.get( model ) || this.getByCid( model ) ) ) {
1071
+ model = _add.call( this, model, options );
1072
+ }
1073
+ this.trigger('relational:add', model, this, options);
1074
+
1075
+ return model;
1076
+ };
1077
+
1078
+ /**
1079
+ * Override 'Backbone.Collection._remove' to trigger 'relation:remove'.
1080
+ */
1081
+ var _remove = Backbone.Collection.prototype._remove;
1082
+ Backbone.Collection.prototype._remove = function( model, options ) {
1083
+ //console.debug('calling _remove on coll=%o; model=%s (%o), options=%o', this, model.cid, model, options );
1084
+ model = _remove.call( this, model, options );
1085
+ this.trigger('relational:remove', model, this, options);
1086
+
1087
+ return model;
1088
+ };
1089
+
1090
+ /**
1091
+ * Override 'Backbone.Collection.trigger' so 'add', 'remove' and 'reset' events are queued until relations
1092
+ * are ready.
1093
+ */
1094
+ var _trigger = Backbone.Collection.prototype.trigger;
1095
+ Backbone.Collection.prototype.trigger = function( eventName ) {
1096
+ if ( eventName === 'add' || eventName === 'remove' || eventName === 'reset' ) {
1097
+ var dit = this, args = arguments;
1098
+ Backbone.Relational.eventQueue.add( function() {
1099
+ _trigger.apply( dit, args );
1100
+ });
1101
+ }
1102
+ else {
1103
+ _trigger.apply( this, arguments );
1104
+ }
1105
+
1106
+ return this;
1107
+ };
1108
+ })();