1 // ==========================================================================
  2 // Project:   SproutCore - JavaScript Application Framework
  3 // Copyright: ©2006-2011 Strobe Inc. and contributors.
  4 //            Portions ©2008-2011 Apple Inc. All rights reserved.
  5 // License:   Licensed under MIT license (see license.js)
  6 // ==========================================================================
  7 
  8 /**
  9   @class
 10 
 11   A `ManyArray` is used to map an array of record ids back to their
 12   record objects which will be materialized from the owner store on demand.
 13 
 14   Whenever you create a `toMany()` relationship, the value returned from the
 15   property will be an instance of `ManyArray`.  You can generally customize the
 16   behavior of ManyArray by passing settings to the `toMany()` helper.
 17 
 18   @extends SC.Enumerable
 19   @extends SC.Array
 20   @since SproutCore 1.0
 21 */
 22 
 23 SC.ManyArray = SC.Object.extend(SC.Enumerable, SC.Array,
 24   /** @scope SC.ManyArray.prototype */ {
 25 
 26   //@if(debug)
 27   /* BEGIN DEBUG ONLY PROPERTIES AND METHODS */
 28 
 29   /* @private */
 30   toString: function () {
 31     var readOnlyStoreIds = this.get('readOnlyStoreIds'),
 32       length = this.get('length');
 33 
 34     return "%@({\n  ids: [%@],\n  length: %@,\n  … })".fmt(sc_super(), readOnlyStoreIds, length);
 35   },
 36 
 37   /* END DEBUG ONLY PROPERTIES AND METHODS */
 38   //@endif
 39 
 40   /**
 41     `recordType` will tell what type to transform the record to when
 42     materializing the record.
 43 
 44     @default null
 45     @type String
 46   */
 47   recordType: null,
 48 
 49   /**
 50     If set, the record will be notified whenever the array changes so that
 51     it can change its own state
 52 
 53     @default null
 54     @type SC.Record
 55   */
 56   record: null,
 57 
 58   /**
 59     If set will be used by the many array to get an editable version of the
 60     storeIds from the owner.
 61 
 62     @default null
 63     @type String
 64   */
 65   propertyName: null,
 66 
 67 
 68   /**
 69     The `ManyAttribute` that created this array.
 70 
 71     @default null
 72     @type SC.ManyAttribute
 73   */
 74   manyAttribute: null,
 75 
 76   /**
 77     The store that owns this record array.  All record arrays must have a
 78     store to function properly.
 79 
 80     @type SC.Store
 81     @property
 82   */
 83   store: function () {
 84     return this.get('record').get('store');
 85   }.property('record').cacheable(),
 86 
 87   /**
 88     The `storeKey` for the parent record of this many array.  Editing this
 89     array will place the parent record into a `READY_DIRTY` state.
 90 
 91     @type Number
 92     @property
 93   */
 94   storeKey: function () {
 95     return this.get('record').get('storeKey');
 96   }.property('record').cacheable(),
 97 
 98   /**
 99     Determines whether the new record (i.e. unsaved) support should be enabled
100     or not.
101 
102     Normally, all records in the many array should already have been previously
103     committed to a remote data store and have an actual `id`. However, with
104     `supportNewRecords` set to true, adding records without an `id `to the many
105     array will assign unique temporary ids to the new records.
106 
107     *Note:* You must update the many array after the new records are successfully
108     committed and have real ids. This is done by calling `updateNewRecordId()`
109     on the many array. In the future this should be automatic.
110 
111     @type Boolean
112     @default false
113     @since SproutCore 1.11.0
114   */
115   supportNewRecords: NO,
116 
117   /**
118     Returns the `storeId`s in read-only mode.  Avoids modifying the record
119     unnecessarily.
120 
121     @type SC.Array
122     @property
123   */
124   readOnlyStoreIds: function () {
125     return this.get('record').readAttribute(this.get('propertyName'));
126   }.property(),
127 
128 
129   /**
130     Returns an editable array of `storeId`s.  Marks the owner records as
131     modified.
132 
133     @type {SC.Array}
134     @property
135   */
136   editableStoreIds: function () {
137     var store    = this.get('store'),
138         storeKey = this.get('storeKey'),
139         pname    = this.get('propertyName'),
140         ret, hash;
141 
142     ret = store.readEditableProperty(storeKey, pname);
143     if (!ret) {
144       hash = store.readEditableDataHash(storeKey);
145       ret = hash[pname] = [];
146     }
147 
148     // if (ret !== this._sc_prevStoreIds) this.recordPropertyDidChange();
149     return ret;
150   }.property(),
151 
152 
153   // ..........................................................
154   // COMPUTED FROM OWNER
155   //
156 
157   /**
158     Computed from owner many attribute
159 
160     @type Boolean
161     @property
162   */
163   isEditable: function () {
164     // NOTE: can't use get() b/c manyAttribute looks like a computed prop
165     var attr = this.manyAttribute;
166     return attr ? attr.get('isEditable') : NO;
167   }.property('manyAttribute').cacheable(),
168 
169   /**
170     Computed from owner many attribute
171 
172     @type String
173     @property
174   */
175   inverse: function () {
176     // NOTE: can't use get() b/c manyAttribute looks like a computed prop
177     var attr = this.manyAttribute;
178     return attr ? attr.get('inverse') : null;
179   }.property('manyAttribute').cacheable(),
180 
181   /**
182     Computed from owner many attribute
183 
184     @type Boolean
185     @property
186   */
187   isMaster: function () {
188     // NOTE: can't use get() b/c manyAttribute looks like a computed prop
189     var attr = this.manyAttribute;
190     return attr ? attr.get('isMaster') : null;
191   }.property("manyAttribute").cacheable(),
192 
193   /**
194     Computed from owner many attribute
195 
196     @type Array
197     @property
198   */
199   orderBy: function () {
200     // NOTE: can't use get() b/c manyAttribute looks like a computed prop
201     var attr = this.manyAttribute;
202     return attr ? attr.get('orderBy') : null;
203   }.property("manyAttribute").cacheable(),
204 
205   // ..........................................................
206   // ARRAY PRIMITIVES
207   //
208 
209   /** @private
210     Returned length is a pass-through to the `storeIds` array.
211 
212     @type Number
213     @property
214   */
215   length: function () {
216     var storeIds = this.get('readOnlyStoreIds');
217     return storeIds ? storeIds.get('length') : 0;
218   }.property('readOnlyStoreIds'),
219 
220   /** @private
221     Looks up the store id in the store ids array and materializes a
222     records.
223   */
224   objectAt: function (idx) {
225     var recs = this._records,
226       storeIds  = this.get('readOnlyStoreIds'),
227       store = this.get('store'),
228       recordType = this.get('recordType'),
229       len = storeIds ? storeIds.length : 0,
230       storeKey, ret, storeId;
231 
232     if (!storeIds || !store) return undefined; // nothing to do
233     if (recs && (ret = recs[idx])) return ret; // cached
234 
235     // If not a good index return undefined
236     if (idx >= len) return undefined;
237     storeId = storeIds.objectAt(idx);
238     if (!storeId) return undefined;
239 
240     // not in cache, materialize
241     if (!recs) this._records = recs = []; // create cache
242 
243     // Handle transient records.
244     if (typeof storeId === SC.T_STRING && storeId.indexOf('_sc_id_placeholder_') === 0) {
245       storeKey = storeId.replace('_sc_id_placeholder_', '');
246     } else {
247       storeKey = store.storeKeyFor(recordType, storeId);
248     }
249 
250     // If record is not loaded already, then ask the data source to retrieve it.
251     if (store.readStatus(storeKey) === SC.Record.EMPTY) {
252       store.retrieveRecord(recordType, null, storeKey);
253     }
254 
255     recs[idx] = ret = store.materializeRecord(storeKey);
256 
257     return ret;
258   },
259 
260   /** @private
261     Pass through to the underlying array.  The passed in objects must be
262     records, which can be converted to `storeId`s.
263   */
264   replace: function (idx, amt, recs) {
265     //@if(debug)
266     if (!this.get('isEditable')) {
267       throw new Error("Developer Error: %@.%@[] is not editable.".fmt(this.get('record'), this.get('propertyName')));
268     }
269     //@endif
270 
271     var storeIds = this.get('editableStoreIds'),
272       recsLen = recs ? (recs.get ? recs.get('length') : recs.length) : 0,
273       parent   = this.get('record'),
274       pname    = this.get('propertyName'),
275       supportNewRecords = this.get('supportNewRecords'),
276       ids, toRemove, inverse, attr, inverseRecord,
277       i;
278 
279     // Create the cache, we will need it.
280     if (!this._records) this._records = [];
281 
282     // map to store keys
283     ids = [];
284     for (i = 0; i < recsLen; i++) {
285       var rec = recs.objectAt(i),
286         id = rec.get('id');
287 
288       if (SC.none(id)) {
289         // If the record inserted doesn't have an id yet, use a unique placeholder based on the storeKey.
290         if (supportNewRecords) {
291           ids[i] = '_sc_id_placeholder_' + rec.get('storeKey');
292         } else {
293           throw new Error("Developer Error: Attempted to add a record without a primary key to a to-many relationship (%@). The record must have a real id or be given a temporary id before it can be used. ".fmt(pname));
294         }
295       } else {
296         ids[i] = id;
297       }
298     }
299 
300     // if we have an inverse - collect the list of records we are about to
301     // remove
302     inverse = this.get('inverse');
303     if (inverse && amt > 0) {
304       // Use a shared array to save on processing.
305       toRemove = SC.ManyArray._toRemove;
306       if (toRemove) SC.ManyArray._toRemove = null; // reuse if possible
307       else toRemove = [];
308 
309       for (i = 0; i < amt; i++) {
310         toRemove[i] = this.objectAt(idx + i);
311       }
312     }
313 
314     // Notify that the content will change.
315     this.arrayContentWillChange(idx, amt, recsLen);
316 
317     // Perform a raw array replace without any KVO checks.
318     // NOTE: the cache must be updated to mirror changes to the storeIds
319     if (ids.length === 0) {
320       storeIds.splice(idx, amt);
321       this._records.splice(idx, amt);
322     } else {
323       var args = [idx, amt].concat(ids);
324       storeIds.splice.apply(storeIds, args);
325       args = [idx, amt].concat(new Array(ids.length)); // Insert empty items into the cache
326       this._records.splice.apply(this._records, args);
327     }
328 
329     // ok, notify records that were removed then added; this way reordered
330     // objects are added and removed
331     if (inverse) {
332 
333       // notify removals
334       for (i = 0; i < amt; i++) {
335         inverseRecord = toRemove[i];
336         attr = inverseRecord ? inverseRecord[inverse] : null;
337         if (attr && attr.inverseDidRemoveRecord) {
338           attr.inverseDidRemoveRecord(inverseRecord, inverse, parent, pname);
339         }
340       }
341 
342       if (toRemove) {
343         toRemove.length = 0; // cleanup
344         if (!SC.ManyArray._toRemove) SC.ManyArray._toRemove = toRemove;
345       }
346 
347       // notify additions
348       for (i = 0; i < recsLen; i++) {
349         inverseRecord = recs.objectAt(i);
350         attr = inverseRecord ? inverseRecord[inverse] : null;
351         if (attr && attr.inverseDidAddRecord) {
352           attr.inverseDidAddRecord(inverseRecord, inverse, parent, pname);
353         }
354       }
355 
356     }
357 
358     // Notify that the content did change.
359     this.arrayContentDidChange(idx, amt, recsLen);
360 
361     // Update our cache! So when the record property change comes back down we can ignore it.
362     this._sc_prevStoreIds = storeIds;
363 
364     // Only mark record dirty if there is no inverse or we are master.
365     if (parent && (!inverse || this.get('isMaster'))) {
366 
367       // We must indicate to the parent that we have been modified, so they can
368       // update their status.
369       parent.recordDidChange(pname);
370     }
371 
372     return this;
373   },
374 
375   // ..........................................................
376   // INVERSE SUPPORT
377   //
378 
379   /**
380     Called by the `ManyAttribute` whenever a record is removed on the inverse
381     of the relationship.
382 
383     @param {SC.Record} inverseRecord the record that was removed
384     @returns {SC.ManyArray} receiver
385   */
386   removeInverseRecord: function (inverseRecord) {
387     // Fast path!
388     if (!inverseRecord) return this; // nothing to do
389 
390     var id = inverseRecord.get('id'),
391       storeIds = this.get('editableStoreIds'),
392       idx = (storeIds && id) ? storeIds.indexOf(id) : -1,
393       record;
394 
395     if (idx >= 0) {
396 
397       // Notify that the content will change.
398       this.arrayContentWillChange(idx, 1, 0);
399 
400       // Perform a raw array replace without any KVO checks.
401       // NOTE: the cache must be updated to mirror changes to the storeIds
402       storeIds.splice(idx, 1);
403       if (this._records) { this._records.splice(idx, 1); }
404 
405       // Notify that the content did change.
406       this.arrayContentDidChange(idx, 1, 0);
407 
408       // Update our cache! So when the record property change comes back down we can ignore it.
409       this._sc_prevStoreIds = storeIds;
410 
411       if (this.get('isMaster') && (record = this.get('record'))) {
412         record.recordDidChange(this.get('propertyName'));
413       }
414     }
415 
416     return this;
417   },
418 
419   /**
420     Called by the `ManyAttribute` whenever a record is added on the inverse
421     of the relationship.
422 
423     @param {SC.Record} inverseRecord the record this array is a part of
424     @returns {SC.ManyArray} receiver
425   */
426   addInverseRecord: function (inverseRecord) {
427     // Fast path!
428     if (!inverseRecord) return this;
429 
430     var storeIds = this.get('editableStoreIds'),
431       orderBy  = this.get('orderBy'),
432       len = storeIds.get('length'),
433       idx, record,
434       removeCount, addCount;
435 
436     // find idx to insert at.
437     if (orderBy) {
438       idx = this._findInsertionLocation(inverseRecord, 0, len, orderBy);
439 
440       // All objects from idx to the end must be removed to do an ordered insert.
441       removeCount = storeIds.length - idx;
442       addCount = storeIds.length - idx + 1;
443     } else {
444       idx = len;
445 
446       removeCount = 0;
447       addCount = 1;
448     }
449 
450     // Notify that the content will change.
451     this.arrayContentWillChange(idx, removeCount, addCount);
452 
453     // Perform a raw array replace without any KVO checks.
454     // NOTE: the cache must be updated to mirror changes to the storeIds
455     storeIds.splice(idx, 0, inverseRecord.get('id'));
456     if (this._records) { this._records.splice(idx, 0, null); }
457 
458     // Notify that the content did change.
459     this.arrayContentDidChange(idx, removeCount, addCount);
460 
461     // Update our cache! So when the record property change comes back down we can ignore it.
462     this._sc_prevStoreIds = storeIds;
463 
464     if (this.get('isMaster') && (record = this.get('record'))) {
465       record.recordDidChange(this.get('propertyName'));
466     }
467 
468     return this;
469   },
470 
471   /** @private binary search to find insertion location */
472   _findInsertionLocation: function (rec, min, max, orderBy) {
473     var idx   = min + Math.floor((max - min) / 2),
474         cur   = this.objectAt(idx),
475         order = this._compare(rec, cur, orderBy);
476 
477     if (order < 0) {
478       // The location is before the first index.
479       if (idx === 0) return idx;
480 
481       // The location is in the lower subset.
482       else return this._findInsertionLocation(rec, 0, idx - 1, orderBy);
483     } else if (order > 0) {
484       // The location is after the current index.
485       if (idx >= max) return idx + 1;
486 
487       // The location is in the upper subset.
488       else return this._findInsertionLocation(rec, idx + 1, max, orderBy);
489     } else {
490       // The location is the current index.
491       return idx;
492     }
493   },
494 
495   /** @private function to compare two objects*/
496   _compare: function (a, b, orderBy) {
497     var t = SC.typeOf(orderBy),
498         ret, idx, len;
499 
500     if (t === SC.T_FUNCTION) ret = orderBy(a, b);
501     else if (t === SC.T_STRING) ret = SC.compare(a, b);
502     else {
503       len = orderBy.get('length');
504       ret = 0;
505       for (idx = 0; ret === 0 && idx < len; idx++) ret = SC.compare(a, b);
506     }
507 
508     return ret;
509   },
510 
511   /**
512     Call this when a new record that was added to the many array previously
513     has been committed and now has a proper `id`. This will fix up the temporary
514     id that was used to allow the new record to be a part of this many array.
515 
516     @return {void}
517   */
518   updateNewRecordId: function (rec) {
519     var storeIds = this.get('editableStoreIds'),
520       idx;
521 
522     // Update the storeIds array with the new record id.
523     idx = storeIds.indexOf('_sc_id_placeholder_' + rec.get('storeKey'));
524 
525     // Beware of records that are no longer a part of storeIds.
526     if (idx >= 0) {
527       storeIds.replace(idx, 1, [rec.get('id')]);
528     }
529   },
530 
531   // ..........................................................
532   // INTERNAL SUPPORT
533   //
534 
535   /** @private */
536   unknownProperty: function (key, value) {
537     var ret;
538     if (SC.typeOf(key) === SC.T_STRING) ret = this.reducedProperty(key, value);
539     return ret === undefined ? sc_super() : ret;
540   },
541 
542   /** @private */
543   init: function () {
544     sc_super();
545 
546     // Initialize.
547     this.recordPropertyDidChange();
548   },
549 
550   /** @private
551     This is called by the parent record whenever its properties change. It is
552     also called by the ChildrenAttribute transform when the attribute is set
553     to a new array.
554   */
555   recordPropertyDidChange: function (keys) {
556     if (keys && !keys.contains(this.get('propertyName'))) return this;
557 
558     var oldLength = this.get('length'),
559       storeIds = this.get('readOnlyStoreIds'),
560       newLength = storeIds ? storeIds.length : 0,
561       prev = this._sc_prevStoreIds;
562 
563     // Fast Path! No actual change to our backing array attribute so we should
564     // not notify any changes.
565     if (storeIds === prev) { return; }
566 
567     // Throw away our cache.
568     this._records = null;
569 
570     // Notify that the content did change.
571     this.arrayContentDidChange(0, oldLength, newLength);
572 
573     // Cache our backing array so we can avoid updates when we haven't actually
574     // changed. See fast path above.
575     this._sc_prevStoreIds = storeIds;
576   }
577 
578 });
579