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