// ========================================================================== // Project: SproutCore - JavaScript Application Framework // Copyright: ©2006-2011 Strobe Inc. and contributors. // Portions ©2008-2011 Apple Inc. All rights reserved. // License: Licensed under MIT license (see license.js) // ==========================================================================

/**

@class

A `ManyArray` is used to map an array of record ids back to their
record objects which will be materialized from the owner store on demand.

Whenever you create a `toMany()` relationship, the value returned from the
property will be an instance of `ManyArray`.  You can generally customize the
behavior of ManyArray by passing settings to the `toMany()` helper.

@extends SC.Enumerable
@extends SC.Array
@since SproutCore 1.0

*/

SC.ManyArray = SC.Object.extend(SC.Enumerable, SC.Array,

/** @scope SC.ManyArray.prototype */ {

//@if(debug)
/* BEGIN DEBUG ONLY PROPERTIES AND METHODS */

/* @private */
toString: function () {
  var readOnlyStoreIds = this.get('readOnlyStoreIds'),
    length = this.get('length');

  return "%@({\n  ids: [%@],\n  length: %@,\n  … })".fmt(sc_super(), readOnlyStoreIds, length);
},

/* END DEBUG ONLY PROPERTIES AND METHODS */
//@endif

/**
  `recordType` will tell what type to transform the record to when
  materializing the record.

  @default null
  @type String
*/
recordType: null,

/**
  If set, the record will be notified whenever the array changes so that
  it can change its own state

  @default null
  @type SC.Record
*/
record: null,

/**
  If set will be used by the many array to get an editable version of the
  storeIds from the owner.

  @default null
  @type String
*/
propertyName: null,

/**
  The `ManyAttribute` that created this array.

  @default null
  @type SC.ManyAttribute
*/
manyAttribute: null,

/**
  The store that owns this record array.  All record arrays must have a
  store to function properly.

  @type SC.Store
  @property
*/
store: function () {
  return this.get('record').get('store');
}.property('record').cacheable(),

/**
  The `storeKey` for the parent record of this many array.  Editing this
  array will place the parent record into a `READY_DIRTY` state.

  @type Number
  @property
*/
storeKey: function () {
  return this.get('record').get('storeKey');
}.property('record').cacheable(),

/**
  Determines whether the new record (i.e. unsaved) support should be enabled
  or not.

  Normally, all records in the many array should already have been previously
  committed to a remote data store and have an actual `id`. However, with
  `supportNewRecords` set to true, adding records without an `id `to the many
  array will assign unique temporary ids to the new records.

  *Note:* You must update the many array after the new records are successfully
  committed and have real ids. This is done by calling `updateNewRecordId()`
  on the many array. In the future this should be automatic.

  @type Boolean
  @default false
  @since SproutCore 1.11.0
*/
supportNewRecords: NO,

/**
  Returns the `storeId`s in read-only mode.  Avoids modifying the record
  unnecessarily.

  @type SC.Array
  @property
*/
readOnlyStoreIds: function () {
  return this.get('record').readAttribute(this.get('propertyName'));
}.property(),

/**
  Returns an editable array of `storeId`s.  Marks the owner records as
  modified.

  @type {SC.Array}
  @property
*/
editableStoreIds: function () {
  var store    = this.get('store'),
      storeKey = this.get('storeKey'),
      pname    = this.get('propertyName'),
      ret, hash;

  ret = store.readEditableProperty(storeKey, pname);
  if (!ret) {
    hash = store.readEditableDataHash(storeKey);
    ret = hash[pname] = [];
  }

  // if (ret !== this._sc_prevStoreIds) this.recordPropertyDidChange();
  return ret;
}.property(),

// ..........................................................
// COMPUTED FROM OWNER
//

/**
  Computed from owner many attribute

  @type Boolean
  @property
*/
isEditable: function () {
  // NOTE: can't use get() b/c manyAttribute looks like a computed prop
  var attr = this.manyAttribute;
  return attr ? attr.get('isEditable') : NO;
}.property('manyAttribute').cacheable(),

/**
  Computed from owner many attribute

  @type String
  @property
*/
inverse: function () {
  // NOTE: can't use get() b/c manyAttribute looks like a computed prop
  var attr = this.manyAttribute;
  return attr ? attr.get('inverse') : null;
}.property('manyAttribute').cacheable(),

/**
  Computed from owner many attribute

  @type Boolean
  @property
*/
isMaster: function () {
  // NOTE: can't use get() b/c manyAttribute looks like a computed prop
  var attr = this.manyAttribute;
  return attr ? attr.get('isMaster') : null;
}.property("manyAttribute").cacheable(),

/**
  Computed from owner many attribute

  @type Array
  @property
*/
orderBy: function () {
  // NOTE: can't use get() b/c manyAttribute looks like a computed prop
  var attr = this.manyAttribute;
  return attr ? attr.get('orderBy') : null;
}.property("manyAttribute").cacheable(),

// ..........................................................
// ARRAY PRIMITIVES
//

/** @private
  Returned length is a pass-through to the `storeIds` array.

  @type Number
  @property
*/
length: function () {
  var storeIds = this.get('readOnlyStoreIds');
  return storeIds ? storeIds.get('length') : 0;
}.property('readOnlyStoreIds'),

/** @private
  Looks up the store id in the store ids array and materializes a
  records.
*/
objectAt: function (idx) {
  var recs = this._records,
    storeIds  = this.get('readOnlyStoreIds'),
    store = this.get('store'),
    recordType = this.get('recordType'),
    len = storeIds ? storeIds.length : 0,
    storeKey, ret, storeId;

  if (!storeIds || !store) return undefined; // nothing to do
  if (recs && (ret = recs[idx])) return ret; // cached

  // If not a good index return undefined
  if (idx >= len) return undefined;
  storeId = storeIds.objectAt(idx);
  if (!storeId) return undefined;

  // not in cache, materialize
  if (!recs) this._records = recs = []; // create cache

  // Handle transient records.
  if (typeof storeId === SC.T_STRING && storeId.indexOf('_sc_id_placeholder_') === 0) {
    storeKey = storeId.replace('_sc_id_placeholder_', '');
  } else {
    storeKey = store.storeKeyFor(recordType, storeId);
  }

  // If record is not loaded already, then ask the data source to retrieve it.
  if (store.readStatus(storeKey) === SC.Record.EMPTY) {
    store.retrieveRecord(recordType, null, storeKey);
  }

  recs[idx] = ret = store.materializeRecord(storeKey);

  return ret;
},

/** @private
  Pass through to the underlying array.  The passed in objects must be
  records, which can be converted to `storeId`s.
*/
replace: function (idx, amt, recs) {
  //@if(debug)
  if (!this.get('isEditable')) {
    throw new Error("Developer Error: %@.%@[] is not editable.".fmt(this.get('record'), this.get('propertyName')));
  }
  //@endif

  var storeIds = this.get('editableStoreIds'),
    recsLen = recs ? (recs.get ? recs.get('length') : recs.length) : 0,
    parent   = this.get('record'),
    pname    = this.get('propertyName'),
    supportNewRecords = this.get('supportNewRecords'),
    ids, toRemove, inverse, attr, inverseRecord,
    i;

  // Create the cache, we will need it.
  if (!this._records) this._records = [];

  // map to store keys
  ids = [];
  for (i = 0; i < recsLen; i++) {
    var rec = recs.objectAt(i),
      id = rec.get('id');

    if (SC.none(id)) {
      // If the record inserted doesn't have an id yet, use a unique placeholder based on the storeKey.
      if (supportNewRecords) {
        ids[i] = '_sc_id_placeholder_' + rec.get('storeKey');
      } else {
        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));
      }
    } else {
      ids[i] = id;
    }
  }

  // if we have an inverse - collect the list of records we are about to
  // remove
  inverse = this.get('inverse');
  if (inverse && amt > 0) {
    // Use a shared array to save on processing.
    toRemove = SC.ManyArray._toRemove;
    if (toRemove) SC.ManyArray._toRemove = null; // reuse if possible
    else toRemove = [];

    for (i = 0; i < amt; i++) {
      toRemove[i] = this.objectAt(idx + i);
    }
  }

  // Notify that the content will change.
  this.arrayContentWillChange(idx, amt, recsLen);

  // Perform a raw array replace without any KVO checks.
  // NOTE: the cache must be updated to mirror changes to the storeIds
  if (ids.length === 0) {
    storeIds.splice(idx, amt);
    this._records.splice(idx, amt);
  } else {
    var args = [idx, amt].concat(ids);
    storeIds.splice.apply(storeIds, args);
    args = [idx, amt].concat(new Array(ids.length)); // Insert empty items into the cache
    this._records.splice.apply(this._records, args);
  }

  // ok, notify records that were removed then added; this way reordered
  // objects are added and removed
  if (inverse) {

    // notify removals
    for (i = 0; i < amt; i++) {
      inverseRecord = toRemove[i];
      attr = inverseRecord ? inverseRecord[inverse] : null;
      if (attr && attr.inverseDidRemoveRecord) {
        attr.inverseDidRemoveRecord(inverseRecord, inverse, parent, pname);
      }
    }

    if (toRemove) {
      toRemove.length = 0; // cleanup
      if (!SC.ManyArray._toRemove) SC.ManyArray._toRemove = toRemove;
    }

    // notify additions
    for (i = 0; i < recsLen; i++) {
      inverseRecord = recs.objectAt(i);
      attr = inverseRecord ? inverseRecord[inverse] : null;
      if (attr && attr.inverseDidAddRecord) {
        attr.inverseDidAddRecord(inverseRecord, inverse, parent, pname);
      }
    }

  }

  // Notify that the content did change.
  this.arrayContentDidChange(idx, amt, recsLen);

  // Update our cache! So when the record property change comes back down we can ignore it.
  this._sc_prevStoreIds = storeIds;

  // Only mark record dirty if there is no inverse or we are master.
  if (parent && (!inverse || this.get('isMaster'))) {

    // We must indicate to the parent that we have been modified, so they can
    // update their status.
    parent.recordDidChange(pname);
  }

  return this;
},

// ..........................................................
// INVERSE SUPPORT
//

/**
  Called by the `ManyAttribute` whenever a record is removed on the inverse
  of the relationship.

  @param {SC.Record} inverseRecord the record that was removed
  @returns {SC.ManyArray} receiver
*/
removeInverseRecord: function (inverseRecord) {
  // Fast path!
  if (!inverseRecord) return this; // nothing to do

  var id = inverseRecord.get('id'),
    storeIds = this.get('editableStoreIds'),
    idx = (storeIds && id) ? storeIds.indexOf(id) : -1,
    record;

  if (idx >= 0) {

    // Notify that the content will change.
    this.arrayContentWillChange(idx, 1, 0);

    // Perform a raw array replace without any KVO checks.
    // NOTE: the cache must be updated to mirror changes to the storeIds
    storeIds.splice(idx, 1);
    if (this._records) { this._records.splice(idx, 1); }

    // Notify that the content did change.
    this.arrayContentDidChange(idx, 1, 0);

    // Update our cache! So when the record property change comes back down we can ignore it.
    this._sc_prevStoreIds = storeIds;

    if (this.get('isMaster') && (record = this.get('record'))) {
      record.recordDidChange(this.get('propertyName'));
    }
  }

  return this;
},

/**
  Called by the `ManyAttribute` whenever a record is added on the inverse
  of the relationship.

  @param {SC.Record} inverseRecord the record this array is a part of
  @returns {SC.ManyArray} receiver
*/
addInverseRecord: function (inverseRecord) {
  // Fast path!
  if (!inverseRecord) return this;

  var storeIds = this.get('editableStoreIds'),
    orderBy  = this.get('orderBy'),
    len = storeIds.get('length'),
    idx, record,
    removeCount, addCount;

  // find idx to insert at.
  if (orderBy) {
    idx = this._findInsertionLocation(inverseRecord, 0, len, orderBy);

    // All objects from idx to the end must be removed to do an ordered insert.
    removeCount = storeIds.length - idx;
    addCount = storeIds.length - idx + 1;
  } else {
    idx = len;

    removeCount = 0;
    addCount = 1;
  }

  // Notify that the content will change.
  this.arrayContentWillChange(idx, removeCount, addCount);

  // Perform a raw array replace without any KVO checks.
  // NOTE: the cache must be updated to mirror changes to the storeIds
  storeIds.splice(idx, 0, inverseRecord.get('id'));
  if (this._records) { this._records.splice(idx, 0, null); }

  // Notify that the content did change.
  this.arrayContentDidChange(idx, removeCount, addCount);

  // Update our cache! So when the record property change comes back down we can ignore it.
  this._sc_prevStoreIds = storeIds;

  if (this.get('isMaster') && (record = this.get('record'))) {
    record.recordDidChange(this.get('propertyName'));
  }

  return this;
},

/** @private binary search to find insertion location */
_findInsertionLocation: function (rec, min, max, orderBy) {
  var idx   = min + Math.floor((max - min) / 2),
      cur   = this.objectAt(idx),
      order = this._compare(rec, cur, orderBy);

  if (order < 0) {
    // The location is before the first index.
    if (idx === 0) return idx;

    // The location is in the lower subset.
    else return this._findInsertionLocation(rec, 0, idx - 1, orderBy);
  } else if (order > 0) {
    // The location is after the current index.
    if (idx >= max) return idx + 1;

    // The location is in the upper subset.
    else return this._findInsertionLocation(rec, idx + 1, max, orderBy);
  } else {
    // The location is the current index.
    return idx;
  }
},

/** @private function to compare two objects*/
_compare: function (a, b, orderBy) {
  var t = SC.typeOf(orderBy),
      ret, idx, len;

  if (t === SC.T_FUNCTION) ret = orderBy(a, b);
  else if (t === SC.T_STRING) ret = SC.compare(a, b);
  else {
    len = orderBy.get('length');
    ret = 0;
    for (idx = 0; ret === 0 && idx < len; idx++) ret = SC.compare(a, b);
  }

  return ret;
},

/**
  Call this when a new record that was added to the many array previously
  has been committed and now has a proper `id`. This will fix up the temporary
  id that was used to allow the new record to be a part of this many array.

  @return {void}
*/
updateNewRecordId: function (rec) {
  var storeIds = this.get('editableStoreIds'),
    idx;

  // Update the storeIds array with the new record id.
  idx = storeIds.indexOf('_sc_id_placeholder_' + rec.get('storeKey'));

  // Beware of records that are no longer a part of storeIds.
  if (idx >= 0) {
    storeIds.replace(idx, 1, [rec.get('id')]);
  }
},

// ..........................................................
// INTERNAL SUPPORT
//

/** @private */
unknownProperty: function (key, value) {
  var ret;
  if (SC.typeOf(key) === SC.T_STRING) ret = this.reducedProperty(key, value);
  return ret === undefined ? sc_super() : ret;
},

/** @private */
init: function () {
  sc_super();

  // Initialize.
  this.recordPropertyDidChange();
},

/** @private
  This is called by the parent record whenever its properties change. It is
  also called by the ChildrenAttribute transform when the attribute is set
  to a new array.
*/
recordPropertyDidChange: function (keys) {
  if (keys && !keys.contains(this.get('propertyName'))) return this;

  var oldLength = this.get('length'),
    storeIds = this.get('readOnlyStoreIds'),
    newLength = storeIds ? storeIds.length : 0,
    prev = this._sc_prevStoreIds;

  // Fast Path! No actual change to our backing array attribute so we should
  // not notify any changes.
  if (storeIds === prev) { return; }

  // Throw away our cache.
  this._records = null;

  // Notify that the content did change.
  this.arrayContentDidChange(0, oldLength, newLength);

  // Cache our backing array so we can avoid updates when we haven't actually
  // changed. See fast path above.
  this._sc_prevStoreIds = storeIds;
}

});