-
Notifications
You must be signed in to change notification settings - Fork 1
/
backbone-rel.js
1115 lines (927 loc) · 46 KB
/
backbone-rel.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
(function(root, factory) {
if(typeof define === 'function' && define.amd) {
// AMD
define(['underscore', 'backbone', 'exports'], function(_, Backbone, exports) {
// Export global even in AMD case in case this script is loaded with
// others that may still expect a global Backbone.
root.Backbone = factory(root, exports, _, Backbone);
});
} else if(typeof exports !== 'undefined') {
// for Node.js or CommonJS
var _ = require('underscore'),
Backbone = require('backbone');
factory(root, exports, _, Backbone);
} else {
// as a browser global
root.Backbone = factory(root, {}, root._, root.Backbone);
}
}(this, function(root, exports, _, BackboneBase) {
var Backbone = _.extend({}, BackboneBase);
var modelOptions = ['url', 'urlRoot', 'collection'];
Backbone.Model = BackboneBase.Model.extend({
references: {},
embeddings: {},
// Property to control whether a related object shall be inlined in this model's JSON representation.
// Useful when the related object shall be saved to the server together with its parent/referencing object.
// If a relationship key is added as a string to this array, the result of #toJSON() will have
// a property of that key, under which the related object's JSON representation is nested.
inlineJSON: [],
// Property to control whether referenced objects shall be fetched automcatically when set.
// - `true` (default) will cause all referenced objects to be fetched automatically
// - `false` will cause that referenced objects are never fetched automatically
// - Setting an array of reference key strings, allows to explicitly specify which references
// shall be auto-fetched.
autoFetchRelated: true,
constructor: function(attributes, options) {
var attrs = attributes || {};
this.cid = _.uniqueId('c');
options || (options = {});
this.attributes = {};
_.extend(this, _.pick(options, modelOptions));
this.relatedObjects = {};
this._relatedObjectsToFetch = [];
this._updateIdRefFor = {};
// handle default values for relations
var defaults,
references = this.references,
referenceAttributeName = this.referenceAttributeName.bind(this);
if(options.parse) attrs = this.parse(attrs, options) || {};
if(defaults = _.result(this, 'defaults')) {
defaults = _.extend({}, defaults); // clone
_.each(_.keys(references), function(refKey) {
// do not set default value for referenced object attribute
// if attrs contain a corresponding ID reference
if(referenceAttributeName(refKey) in attrs && refKey in defaults) {
delete defaults[refKey];
}
});
attrs = _.defaults({}, attrs, defaults);
}
this.set(attrs, options);
this.changed = {};
if(!this.isNew()) {
this._autoFetchEmbeddings(true);
}
this.initialize.apply(this, arguments);
},
// Returns the URL for this model
//
//
url: function() {
var base =
_.result(this, 'urlRoot') ||
_.result(this.collection, 'url');
var suffix = _.result(this, 'urlSuffix');
if(base) {
if(this.isNew()) return base;
return base.replace(/([^\/])$/, '$1/') + encodeURIComponent(this.id);
} else if(this.parent) {
if(this.parent.isNew() && !this.parent.parent) {
throw new Error("Could not get the parent model's URL as it has not been saved yet.");
}
base = _.result(this.parent, 'url');
if(base && suffix) {
return base.replace(/([^\/])$/, '$1/') + suffix.replace(/(\/?)(.*)/, '$2');
}
}
throw new Error('Could not build url for the model with ID "' + this.id + '" (URL suffix: "' + suffix + '")');
},
// For embedded models, returns the suffix to append to the parent model's URL
// for building the URL for the embedded model instance.
urlSuffix: function() {
var self = this,
parent = this.parent;
return parent && _.find(_.keys(parent.embeddings), function(key) {
return parent.get(key) === self;
});
},
// For models with references, returns the attribute name under which the IDs of referenced
// objects for the given reference key are stored.
// Per default, the attribute name is built by using the reference key + the ID attribute of the
// referenced model. E.g: "userId" for reference key "user" to a model with ID attribute "id",
// "userIds" for a to-many reference.
// Override this method to customize the reference attribute naming pattern.
referenceAttributeName: function(referenceKey) {
var referencedModel = resolveRelClass(this.references[referenceKey]);
return refKeyToIdRefKey(referencedModel, referenceKey);
},
get: function(attr) {
if(this.embeddings[attr] || this.references[attr]) {
// return related object if the key corresponds to a reference or embedding
return this.relatedObjects[attr];
} else {
// otherwise return the regular attribute
return BackboneBase.Model.prototype.get.apply(this, arguments);
}
},
// Set a hash of model attributes and relations on the object, firing `"change"`. This is
// the core primitive operation of a model, updating the data and notifying
// anyone who needs to know about the change in state. The heart of the beast.
// ATTENTION: This is a full override of Backbone's default implementation meaning that
// it will not call the base class method. If you are using third Backbone extensions that
// override #set, make sure that these extend backbone-rel' Model class.
set: function(key, val, options) {
var attr, attrs, unset, changes, silent, changing, prev, current, referenceKey;
if(key === null) return this;
// Handle both `"key", value` and `{key: value}` -style arguments.
if(typeof key === 'object') {
attrs = key;
options = val;
} else {
(attrs = {})[key] = val;
}
options || (options = {});
// Pass the setOriginId down to the nested set calls via options.
// Also support `nestedSetOptions` to define options that shall only be applied to nested
// #set calls for related object.
var nestedOptions = _.extend(
{ setOriginId: _.uniqueId() }, options,
{ clear: undefined }, // `clear` option should not propagate to nested set calls
{ nestedSetOptions: undefined },
options.nestedSetOptions
);
if(nestedOptions.collection) {
// `collection` option should not propagate to nested set calls
delete nestedOptions.collection;
}
this._deepChangePropagatedFor = [];
// Run validation.
if(!this._validate(attrs, options)) return false;
// Extract attributes and options.
unset = options.unset;
silent = options.silent;
changes = [];
changing = this._changing;
this._changing = true;
if(!changing) {
this._previousAttributes = _.clone(this.attributes);
this._previousRelatedObjects = _.clone(this.relatedObjects);
this.changed = {};
}
current = this.attributes, prev = this._previousAttributes;
// Check for changes of `id`.
if(this.idAttribute in attrs) this.id = attrs[this.idAttribute];
// Precalculate the idRefKeys for all references to improve performance of the lookups
var refKeys = _.keys(this.references);
var refAndIdRefKeys = {};
var i;
for(i=0; i<refKeys.length; i++) {
refAndIdRefKeys[refKeys[i]] = refKeys[i];
refAndIdRefKeys[this.referenceAttributeName(refKeys[i])] = refKeys[i];
}
var findReferenceKey = function(key) {
return refAndIdRefKeys[key];
};
// If `clear` is set, calculate the keys to be unset and add those keys to attrs
var keysToUnset = [];
if(options.clear) {
var defaults = _.result(this, 'defaults') || {};
keysToUnset = _.difference(
_.union(_.keys(this.attributes), _.keys(this.relatedObjects)),
_.keys(attrs),
_.map(_.keys(attrs), findReferenceKey)
);
// clone because the keysToUnset array is modified from within the loop
var keysToUnsetCopy = _.clone(keysToUnset);
for(i=0, l=keysToUnsetCopy.length; i<l; ++i) {
var keyToUnset = keysToUnsetCopy[i];
if(defaults.hasOwnProperty(keyToUnset)) {
// reset to default value instead of deleting
var defVal = defaults[keyToUnset];
// ensure that the default for a reference/embedding is an actual Backbone
// model/collection and not just a plain JSON hash or array (which would be
// applied to the current referenced objects instead of replacing them)
if(defVal && !defVal._representsToMany && !defVal._representsToOne) {
var relationship = this.embeddings[keyToUnset] || this.references[keyToUnset];
var RelClass = relationship && resolveRelClass(relationship);
if(RelClass) defVal = new RelClass(defVal, nestedOptions);
}
attrs[keyToUnset] = defVal;
keysToUnset = _.without(keysToUnset, keyToUnset);
} else {
// add to attrs just to make sure the key will be traversed in the for loop
attrs[keyToUnset] = void 0;
}
}
}
// For each `set` attribute, update or delete the current value.
for (attr in attrs) {
val = attrs[attr];
if(this.embeddings[attr]) {
var opts = _.extend({}, nestedOptions, { clear: options.clear }, { unset: unset || _.includes(keysToUnset, attr) });
this._setEmbedding(attr, val, opts, changes);
} else if(referenceKey = findReferenceKey(attr)) {
// side-loaded JSON structures take precedence over ID references
if(attr !== referenceKey && attrs[referenceKey]) {
// is ID ref, but also side-loaded data is present in attrs
continue; // ignore attr
}
var opts = _.extend({}, nestedOptions, { unset: unset || _.includes(keysToUnset, referenceKey) });
this._setReference(referenceKey, val, opts, changes);
} else {
// default Backbone behavior for plain attribute set
if(!_.isEqual(current[attr], val)) changes.push(attr);
if(!_.isEqual(prev[attr], val)) {
this.changed[attr] = val;
} else {
delete this.changed[attr];
}
unset || _.includes(keysToUnset, attr) ? delete current[attr] : current[attr] = val;
}
}
var currentAll = _.extend({}, current, this.relatedObjects);
// Trigger all relevant attribute changes.
if(!silent) {
if(changes.length) this._pending = true;
var l;
for (i = 0, l = changes.length; i < l; i++) {
this.trigger('change:' + changes[i], this, currentAll[changes[i]], options);
}
}
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if(changing) return this;
if(!silent) {
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
// Trigger original 'deepchange' event, which will be propagated through the related object graph
if(!silent && changes.length && !_.includes(this._deepChangePropagatedFor, nestedOptions.setOriginId)) {
this._deepChangePropagatedFor.push(nestedOptions.setOriginId);
this.trigger('deepchange', this, _.extend({ setOriginId: nestedOptions.setOriginId }, options));
this.trigger('deepchange_propagated', this, _.extend({ setOriginId: nestedOptions.setOriginId }, options));
}
// finally, fetch all related objects that need a fetch
this._fetchRelatedObjects();
return this;
},
// Fetches the related object for each key in the provided keys array
// If no keys array is provided, it fetches the related objects for all
// relations that have not been synced before
fetchRelated: function(keys) {
if(!keys) {
var embeddingKeys = _.filter(_.keys(this.embeddings), function(key) {
return !this.get(key) || (!this.get(key).isSyncing && !this.get(key).isSynced);
}, this);
var referencesKeys = _.filter(_.keys(this.references), function(key) {
return this.get(key) && (!this.get(key).isSyncing && !this.get(key).isSynced);
}, this);
keys = _.union(embeddingKeys, referencesKeys);
}
if(_.isString(keys)) {
keys = [keys];
}
for(var i=0; i<keys.length; i++) {
var key = keys[i];
if(!this.embeddings[key] && !this.references[key]) {
throw new Error("Invalid relationship key '" + key + "'");
}
// init embeddings
if(!this.get(key) && this.embeddings[key]) {
var RelClass = resolveRelClass(this.embeddings[key]);
this.set(key, new RelClass());
}
var relatedObject = this.get(key);
if(relatedObject && !relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) {
this._relatedObjectsToFetch.push(relatedObject);
}
}
this._fetchRelatedObjects();
},
// Sets the parent of an embedded object
// If the optional keyInParent parameter is omitted, is is automatically detected
setParent: function(parent, keyInParent) {
var self = this;
this.keyInParent = keyInParent || _.find(_.keys(parent.embeddings), function(key) {
return parent.get(key) === self;
});
if(!this.keyInParent) {
throw new Error("A key for the embedding in the parent must be specified as it could not be detected automatically.");
}
this.parent = parent;
if(this.parent.get(this.keyInParent) !== this) {
this.parent.set(this.keyInParent, this);
}
this.trigger("embedded", this, parent, keyInParent);
},
// Override #previous to add support for getting previous values of references and embeddings
// in "change" events
previous: function(attr) {
var result = BackboneBase.Model.prototype.previous.apply(this, arguments);
if(result) return result;
if (attr === null || !this._previousRelatedObjects) return null;
return this._previousRelatedObjects[attr];
},
// Override #toJSON to add support for inlining JSON representations of related objects
// in the JSON of this model. The related objects to be inlined can be specified via the
// `inlineJSON` property or option.
toJSON: function(options) {
options = options || {};
var self = this;
var json = BackboneBase.Model.prototype.toJSON.apply(this, arguments);
var inlineJSON = _.uniq(_.compact(_.flatten(
_.union([options.inlineJSON], [_.result(this, "inlineJSON")])
)));
_.each(inlineJSON, function(key) {
var obj = self;
var path = key.split("."),
nestedJson = json;
while(obj && path.length > 0 && _.isFunction(obj.toJSON)) {
key = path.shift();
obj = obj.get(key);
if(obj && _.isFunction(obj.toJSON)) {
// nest JSON represention ob embedded object into the hierarchy
nestedJson[key] = obj.toJSON();
nestedJson = nestedJson[key];
} else if(obj===null) {
// if an embedded object was unset, i.e., set to null, we have to
// notify the server by nesting a null value into the JSON hierarchy
nestedJson[key] = null;
}
}
});
return json;
},
// Override #fetch to add support for auto-fetching of embedded objects
fetch: function() {
var result = BackboneBase.Model.prototype.fetch.apply(this, arguments);
this._autoFetchEmbeddings();
return result;
},
// Override #sync to force PUT method when creating embedded models
sync: function(method, obj, options) {
this._beforeSync();
options = wrapOptionsCallbacks(this._afterSyncBeforeSet.bind(this), options);
if(this.parent && method === "create") method = "update"; // always PUT embedded models
if(options.forceMethod) {
method = options.forceMethod;
}
return BackboneBase.Model.prototype.sync.apply(this, arguments);
},
//
// PRIVATE METHODS
//
_setEmbedding: function(key, value, options, changes) {
var RelClass = resolveRelClass(this.embeddings[key]);
var current = this.relatedObjects[key];
if(options.unset || options.clear) {
delete this.relatedObjects[key];
}
if(!options.unset && value && value !== current) {
if(value._representsToMany || value._representsToOne) {
// a model object is directly assigned
// set its parent
this.relatedObjects[key] = value;
this.relatedObjects[key].setParent(this, key);
} else if(!this.relatedObjects[key]) {
// || (!_.isArray(value) && !this.relatedObjects[key].isNew() && this.relatedObjects[key].id !== value[this.relatedObjects[key].idAttribute])) {
// first assignment of an embedded model //or assignment of an embedded model with a different ID
// create embedded model and set its parent
this.relatedObjects[key] = new RelClass(value, options);
this.relatedObjects[key].setParent(this, key);
} else {
// update embedded model's attributes
if(this.relatedObjects[key]._representsToMany) {
this.relatedObjects[key][options.reset ? 'reset' : 'set'](value, options);
} else {
if(options.parse) {
value = this.relatedObjects[key].parse(value, options);
}
this.relatedObjects[key].set(value, options);
}
}
} else if(!options.unset) {
// set new embedded object or null/undefined
this.relatedObjects[key] = value;
}
if(current !== this.relatedObjects[key]) {
changes.push(key);
this._listenToRelatedObject(key, current);
// unset current's parent property
if(current) {
current.parent = null;
}
}
if(this._previousRelatedObjects[key] !== this.relatedObjects[key]) {
this.changed[key] = this.relatedObjects[key];
} else {
delete this.changed[key];
}
},
_setReference: function(key, value, options, changes) {
var RelClass = resolveRelClass(this.references[key]),
idRef = this.referenceAttributeName(key);
var current = this.relatedObjects[key],
currentId = this.attributes[idRef];
if(options.unset || options.clear) {
delete this.relatedObjects[key];
delete this.attributes[idRef];
}
if(!options.unset && value!==undefined && value!==null) {
if(RelClass.prototype._representsToOne) {
// handling to-one relation
this._setToOneReference(key, RelClass, value, options);
} else if(RelClass.prototype._representsToMany) {
// handling to-many relation
this._setToManyReference(key, RelClass, value, options);
}
this._ensureIdReference(idRef, key);
} else if(!options.unset) {
// set `undefined` or `null`
this.relatedObjects[key] = value;
this.attributes[idRef] = value;
}
if(!_.isEqual(currentId, this.attributes[idRef])) {
changes.push(idRef);
}
if(current !== this.relatedObjects[key]) {
changes.push(key);
this._listenToRelatedObject(key, current);
}
if(this._previousRelatedObjects[key] !== this.relatedObjects[key]) {
this.changed[key] = this.relatedObjects[key];
} else {
delete this.changed[key];
}
if(!_.isEqual(this._previousAttributes[idRef], this.attributes[idRef])) {
this.changed[idRef] = this.attributes[idRef];
} else {
delete this.changed[idRef];
}
},
_ensureIdReference: function(idRef, refKey) {
var relatedObject = this.relatedObjects[refKey];
if(relatedObject._representsToOne) {
// if the relatedObject is new, i.e., it doesn't have an ID yet
// we need to update the reference as soon as the referenced objects
// got assigned an ID
if(relatedObject.isNew()) {
if(this.attributes[idRef]) {
delete this.attributes[idRef];
}
relatedObject.once("change:" + (relatedObject.idAttribute||"id"), function() {
this.set(idRef, relatedObject.id);
}, this);
} else {
this.attributes[idRef] = relatedObject.id;
}
} else {
// if any one of the referenced objects is new,
// we need to update the ID ref array as soon as that item
// got assigned an ID
var atLeastOneItemIsNew = false,
idAttr;
this.attributes[idRef] = _.compact(relatedObject.map(function(m) {
if(m.isNew()) {
atLeastOneItemIsNew = true;
idAttr = m.idAttribute || "id";
return undefined;
} else {
return m.id;
}
}));
if(atLeastOneItemIsNew) {
relatedObject.once("change:" + idAttr, this._ensureIdReference.bind(this, idRef, refKey));
}
}
},
_setToOneReference: function(key, RelClass, value, options) {
var relatedObject = this.relatedObjects[key];
var id = value[RelClass.prototype.idAttribute||"id"] || value;
// reset relatedObject if the ID reference changed
// if the current related object does not yet have an id and the new value is side-loaded
// data, do not reset, but assign the new id to the current related object
if(relatedObject && relatedObject.id !== id && (relatedObject[relatedObject.idAttribute||"id"] || !(value instanceof Object))) {
relatedObject = undefined;
}
if(value._representsToOne) {
// directly assign a model
if(value===relatedObject) return;
relatedObject = value;
this.relatedObjects[key] = relatedObject;
return;
}
if(value instanceof Object) {
// if the related model data is side-loaded,
// create/update the related model instance
if(relatedObject) {
if(options.parse) {
value = relatedObject.parse(value, options);
}
relatedObject.set(value, options);
} else {
relatedObject = new RelClass(value, options);
}
relatedObject.isSynced = true;
// remove side-loaded object from the models to fetch
if(relatedObject !== this)
this._relatedObjectsToFetch = _.without(this._relatedObjectsToFetch, relatedObject);
} else {
// if only an ID reference is provided,
// instantiate the model
if(!relatedObject) {
var attrs = {};
attrs[RelClass.prototype.idAttribute||"id"] = id;
relatedObject = new RelClass(attrs, options);
// auto-fetch related model if its url can be built
var autoFetch = this.autoFetchRelated === true ||
(_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key));
var url;
try {
url = _.result(relatedObject, "url");
} catch(e) {
if(autoFetch && console && _.isFunction(console.warn)) {
console.warn("Could not build url to auto-fetch referenced model for key '" + key +"'", e.stack);
}
}
if(autoFetch && url && !relatedObject.isSynced && !relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) {
this._relatedObjectsToFetch.push(relatedObject);
}
}
}
this.relatedObjects[key] = relatedObject;
},
_setToManyReference: function(key, RelClass, value, options) {
var ItemModel = RelClass.prototype.model;
var relatedObject = this.relatedObjects[key];
if(value._representsToMany) {
// a collection model is directly assigned
if(value===relatedObject) return;
// teardown relation to old collection
if(relatedObject) {
relatedObject.parent = undefined; // TODO get rid of this here!!!
}
// setup relation to the new collection
relatedObject = value;
relatedObject.parent = this; // TODO get rid of this here!!!
this.relatedObjects[key] = relatedObject;
return;
}
// expect an array of IDs or model json objects
if(!_.isArray(value)) {
throw new Error("Got an unexpected value to set reference '" + key + "'");
}
if(!relatedObject) {
relatedObject = new RelClass([], {parent: this});
}
// iterate all related items and get/initialize/fetch the model objects
var modelArray = _.map(value, function(itemData) {
var id = itemData.id || itemData;
// try to get the related model from the current relatedObject collection
var item = relatedObject.get(id);
if(itemData instanceof Backbone.Model) {
return itemData;
}
if(itemData instanceof Object) {
// if the related model data is sideloaded,
// create/update the related model instance
if(item) {
if(options.parse) {
itemData = item.parse(itemData, options);
}
item.set(itemData, options);
} else {
item = new ItemModel(itemData, options);
}
item.isSynced = true;
// remove side-loaded object from the models to fetch
if(item !== this) {
this._relatedObjectsToFetch = _.without(this._relatedObjectsToFetch, item);
}
} else {
// if only an ID reference is provided
// and the relation could not be resolved to an already loaded model,
// instantiate the model
if(!item) {
var attrs = {};
attrs[ItemModel.prototype.idAttribute||"id"] = id;
item = new ItemModel(attrs, _.extend({}, options, { parse: undefined }));
// auto-fetch related model if its url can be built
var autoFetch = this.autoFetchRelated === true ||
(_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key));
var url;
try {
url = _.result(item, "url");
} catch(e) {
if(autoFetch && console && _.isFunction(console.warn)) {
console.warn("Could not build url to auto-fetch referenced model for key '" + key + "'", e);
}
}
if(autoFetch && url && !item.isSynced && !item.isSyncing && !_.includes(this._relatedObjectsToFetch, item)) {
this._relatedObjectsToFetch.push(item);
}
}
}
return item;
}, this);
// important: do not merge into existing models as this might cause running into endless set loops for circular relations
// merging of related model items' attributes is already done in the _.map() above
relatedObject.set(modelArray, {merge:false});
this.relatedObjects[key] = relatedObject;
},
_listenToRelatedObject: function(key, current) {
if(current) {
// stop propagating 'deepchange' of current related object
this.stopListening(current, 'deepchange', this._propagateDeepChange);
// stop listening to destroy and ID change events
if(current._representsToOne) {
this.stopListening(current, 'destroy', this._relatedObjectDestroyHandler);
this.stopListening(current, 'change:' + (current.idAttribute || "id"), this._updateIdRefFor[key]);
} else {
this.stopListening(current, 'add remove reset change:' + (current.idAttribute || "id"), this._updateIdRefFor[key]);
}
}
// start propagating 'deepchange' of new related object
if(this.relatedObjects[key]) {
this.listenTo(this.relatedObjects[key], 'deepchange', this._propagateDeepChange);
if(this.relatedObjects[key]._representsToOne) {
// listen to destroy to unset references
this.listenTo(this.relatedObjects[key], 'destroy', this._relatedObjectDestroyHandler);
// listen to changes of the ID to update ref
this._updateIdRefFor[key] = this._updateIdRefFor[key] || this._updateIdRef.bind(this, key);
this.listenTo(this.relatedObjects[key], 'change:' + (this.relatedObjects[key].idAttribute || "id"), this._updateIdRefFor[key]);
} else {
// listen to changes in the of item IDs and collection manipulations to update ID ref array
this._updateIdRefFor[key] = this._updateIdRefFor[key] || this._updateIdRef.bind(this, key);
this.listenTo(this.relatedObjects[key], 'add remove reset change:' + (this.relatedObjects[key].idAttribute || "id"), this._updateIdRefFor[key]);
}
}
},
_updateIdRef: function(key) {
if(this.references[key]) {
var idRef = this.referenceAttributeName(key);
this._ensureIdReference(idRef, key);
this.trigger("change:" + idRef, this, this.get(idRef), {});
this.trigger("change", this, {});
}
},
_autoFetchEmbeddings: function(onlyUndefinedEmbeddings) {
var embeddingsKeys = _.keys(this.embeddings);
for(var i=0; i<embeddingsKeys.length; i++) {
var key = embeddingsKeys[i];
var autoFetch = this.autoFetchRelated === true ||
(_.isArray(this.autoFetchRelated) && _.includes(this.autoFetchRelated, key));
if(autoFetch) {
if(!this.get(key)) {
var RelClass = resolveRelClass(this.embeddings[key]);
this.set(key, new RelClass());
} else if(onlyUndefinedEmbeddings) {
continue;
}
var relatedObject = this.get(key);
if(!relatedObject.isSyncing && !_.includes(this._relatedObjectsToFetch, relatedObject)) {
this._relatedObjectsToFetch.push(relatedObject);
}
}
}
this._fetchRelatedObjects();
},
_beforeSync: function() {
this.isSyncing = true;
// make sure that "deepsync" is always triggered after "sync"
this._relatedObjectsToFetch.push(this);
var self = this;
var syncCb = function() {
self._relatedObjectFetchSuccessHandler(self);
self.off("error", errorCb);
};
var errorCb = function() {
self._relatedObjectsToFetch.splice(self._relatedObjectsToFetch.indexOf(self), 1);
self.off("sync", syncCb);
};
this.once("sync", syncCb);
this.once("error", errorCb);
},
_afterSyncBeforeSet: function() {
this.isSynced = true;
delete this.isSyncing;
},
_propagateDeepChange: function(changedModelOrCollection, opts) {
// make sure that 'deepchange' is only triggered once, also when set operations are nested
if(_.includes(this._deepChangePropagatedFor, opts.setOriginId)) {
return;
}
this._deepChangePropagatedFor.push(opts.setOriginId);
this.trigger('deepchange', changedModelOrCollection, opts);
changedModelOrCollection.once('deepchange_propagated', function() {
this.trigger('deepchange_propagated', changedModelOrCollection, opts);
}, this);
},
_fetchRelatedObjects: function() {
for (var i=0; i<this._relatedObjectsToFetch.length; i++) {
var model = this._relatedObjectsToFetch[i];
if(model===this) continue; // do not fetch again while setting
// test whether fetching has already been triggered by another relation
if(model.isSyncing) {
model.once("sync", this._relatedObjectFetchSuccessHandler.bind(this, model));
continue;
} else if(model.isSynced) {
this._relatedObjectFetchSuccessHandler(model);
continue;
}
model.fetch({
success: this._relatedObjectFetchSuccessHandler.bind(this),
error: this._relatedObjectFetchErrorHandler.bind(this),
isAutoFetch: true
});
}
},
// This callback is executed after every successful fetch of related objects after
// these have been set as a reference auto-fetched as an embedding. It is responsible
// for eventually triggering the 'deepsync' event.
_relatedObjectFetchSuccessHandler: function(obj) {
this._relatedObjectsToFetch.splice(this._relatedObjectsToFetch.indexOf(obj), 1);
if(this._relatedObjectsToFetch.length === 0) {
this.trigger("deepsync", this);
}
},
// propagate errors when automatically fetching related models
_relatedObjectFetchErrorHandler: function(obj, resp, options) {
this._relatedObjectsToFetch.splice(this._relatedObjectsToFetch.indexOf(obj), 1);
this.trigger('error', obj, resp, options);
},
// This callback ensures that relations are unset, when a related object is destroyed
_relatedObjectDestroyHandler: function(destroyedObject) {
_.each(this.relatedObjects, function(relObj, key) {
if(relObj === destroyedObject) {
this.unset(key);
}
}, this);
},
_representsToOne: true
});
Backbone.Collection = BackboneBase.Collection.extend({
constructor: function() {
var triggerOriginalDeepChange = function(options) {
options = options || {};
// Trigger original 'deepchange' event, which will be propagated through the related object graph
var originId = options.setOriginId || _.uniqueId();
this._deepChangePropagatedFor.push(originId);
this.trigger('deepchange', this, _.extend({ setOriginId: originId }, options));
this.trigger('deepchange_propagated', this, _.extend({ setOriginId: originId }, options));
}.bind(this);
this.on('add remove', function(model, collection, options) {
triggerOriginalDeepChange(options);
});
this.on('reset', function(collection, options) {
triggerOriginalDeepChange(options);
});
this.on('sort', function(collection, options) {
triggerOriginalDeepChange(options);
});
return BackboneBase.Collection.prototype.constructor.apply(this, arguments);
},
url: function() {
var base = _.result(this, 'urlRoot');
if(base) {
return base;
} else if(this.parent) {
if(this.parent.isNew() && !this.parent.parent) {
throw new Error("Could not get the parent model's URL as it has not been saved yet.");
}
base = _.result(this.parent, 'url');
var suffix = _.result(this, 'urlSuffix');
if(base && suffix) {
return base.replace(/([^\/])$/, '$1/') + suffix.replace(/(\/?)(.*)/, '$2');
}
}
throw new Error('Could not build url for the collection');
},
urlSuffix: function() {
var self = this,
parent = this.parent;
return parent && _.find(_.keys(parent.embeddings), function(key) {
return parent.get(key) === self;
});
},
set: function() {
this._deepChangePropagatedFor = [];
return BackboneBase.Collection.prototype.set.apply(this, arguments);
},
// Sets the parent for an embedded collection
// If the optional keyInParent parameter is omitted, is is automatically detected
setParent: function(parent, keyInParent) {
var self = this;
this.keyInParent = keyInParent || _.find(_.keys(parent.embeddings), function(key) {
return parent.get(key) === self;
});
if(!this.keyInParent) {
throw new Error("A key for the embedding in the parent must be specified as it could not be detected automatically.");
}
this.parent = parent;
if(this.parent.get(this.keyInParent) !== this) {
this.parent.set(this.keyInParent, this);
}
this.trigger("embedded", this, parent, keyInParent);
},
sync: function() {
this._beforeSync();
//options = wrapOptionsCallbacks(this._afterSetBeforeTrigger, options);
return BackboneBase.Collection.prototype.sync.apply(this, arguments);
},
fetch: function(options) {
options = wrapOptionsCallbacks(this._afterSetBeforeTrigger.bind(this), options);
// auto-fetch embeddings of items
//this.once("sync", function() {
// this.each(function(item) {
// item._autoFetchEmbeddings();
// });
//}, this);