// ========================================================================== // 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) // ========================================================================== sc_require('system/store'); /** @class A nested store can buffer changes to a parent store and then commit them all at once. You usually will use a `NestedStore` as part of store chaining to stage changes to your object graph before sharing them with the rest of the application. Normally you will not create a nested store directly. Instead, you can retrieve a nested store by using the `chain()` method. When you are finished working with the nested store, `destroy()` will dispose of it. @extends SC.Store @since SproutCore 1.0 */ SC.NestedStore = SC.Store.extend( /** @scope SC.NestedStore.prototype */ { /** This is set to YES when there are changes that have not been committed yet. @type Boolean @default NO */ hasChanges: NO, /** The parent store this nested store is chained to. Nested stores must have a parent store in order to function properly. Normally, you create a nested store using the `SC.Store#chain()` method and this property will be set for you. @type SC.Store @default null */ parentStore: null, /** `YES` if the store is nested. Walk like a duck @type Boolean @default YES */ isNested: YES, /** If YES, then the attribute hash state will be locked when you first read the data hash or status. This means that if you retrieve a record then change the record in the parent store, the changes will not be visible to your nested store until you commit or discard changes. If `NO`, then the attribute hash will lock only when you write data. Normally you want to lock your attribute hash the first time you read it. This will make your nested store behave most consistently. However, if you are using multiple sibling nested stores at one time, you may want to turn off this property so that changes from one store will be reflected in the other one immediately. In this case you will be responsible for ensuring that the sibling stores do not edit the same part of the object graph at the same time. @type Boolean @default YES */ lockOnRead: YES, /** @private Array contains the base revision for an attribute hash when it was first cloned from the parent store. If the attribute hash is edited and committed, the commit will fail if the parent attributes hash has been edited since. This is a form of optimistic locking, hence the name. Each store gets its own array of locks, which are selectively populated as needed. Note that this is kept as an array because it will be stored as a dense array on some browsers, making it faster. @type Array @default null */ locks: null, /** @private An array that includes the store keys that have changed since the store was last committed. This array is used to sync data hash changes between chained stores. For a log changes that may actually be committed back to the server see the changelog property. @type SC.Set @default YES */ chainedChanges: null, // .......................................................... // STORE CHAINING // /** `find()` cannot accept REMOTE queries in a nested store. This override will verify that condition for you. See `SC.Store#find()` for info on using this method. @param {SC.Query} query query object to use. @returns {SC.Record|SC.RecordArray} */ find: function(query) { if (query && query.isQuery && query.get('location') !== SC.Query.LOCAL) { throw new Error("SC.Store#find() can only accept LOCAL queries in nested stores"); } return sc_super(); }, /** Propagate this store's changes to its parent. If the store does not have a parent, this has no effect other than to clear the change set. @param {Boolean} force if YES, does not check for conflicts first @returns {SC.Store} receiver */ commitChanges: function(force) { if (this.get('hasChanges')) { var pstore = this.get('parentStore'); pstore.commitChangesFromNestedStore(this, this.get('chainedChanges'), force); } // clear out custom changes - even if there is nothing to commit. this.reset(); return this; }, /** An array of store keys for all conflicting records with the parent store. If there are no conflicting records, this property will be null. @readonly @field @type Array @default null */ conflictedStoreKeys: function () { var ret = null; if (this.get('hasChanges')) { var pstore = this.get('parentStore'), locks = this.locks, revisions = pstore.revisions; if (locks && revisions) { var changes = this.get('chainedChanges'); for (var i = 0, len = changes.length; i < len; i++) { var storeKey = changes[i], lock = locks[storeKey] || 1, revision = revisions[storeKey] || 1; // If the same revision for the item does not match the current revision, then someone has // changed the data hash in this store and we have a conflict. if (lock < revision) { if (!ret) ret = []; ret.push(storeKey); } } } } return ret; }.property('chainedChanges').cacheable(), /** Propagate this store's successful changes to its parent (if exists). At the end, it clears the local, private status of the committed records therefore the method can be called several times until the full transaction is successful or editing is abandoned @param {Boolean} force if YES, does not check for conflicts first @returns {SC.Store} receiver */ commitSuccessfulChanges: function(force) { var chainedChanges = this.get('chainedChanges'); if (this.get('hasChanges') && chainedChanges) { var dataHashes = this.dataHashes, revisions = this.revisions, statuses = this.statuses, editables = this.editables, locks = this.locks; var successfulChanges = chainedChanges.filter( function(storeKey) { var state = this.readStatus(storeKey); return state === SC.Record.READY_CLEAN || state === SC.Record.DESTROYED_CLEAN; }, this ); var pstore = this.get('parentStore'); pstore.commitChangesFromNestedStore(this, successfulChanges, force); // remove the local status so these records that have been successfully committed on the server // are no longer retrieved from this nested store but from the parent for (var i = 0, len = successfulChanges.get('length'); i < len; i++) { var storeKey = successfulChanges.objectAt(i); if (dataHashes && dataHashes.hasOwnProperty(storeKey)) { delete dataHashes[storeKey]; } if (revisions && revisions.hasOwnProperty(storeKey)) { delete revisions[storeKey]; } if (editables) { delete editables[storeKey]; } if (locks) { delete locks[storeKey]; } if (statuses && statuses.hasOwnProperty(storeKey)) { delete statuses[storeKey]; } chainedChanges.remove(storeKey); } // Indicate that chainedChanges has changed. if (successfulChanges.length > 0) { this.notifyPropertyChange('chainedChanges'); } } return this; }, /** Discard the changes made to this store and reset the store. @returns {SC.Store} receiver */ discardChanges: function() { // any locked records whose rev or lock rev differs from parent need to // be notified. var records, locks; if ((records = this.records) && (locks = this.locks)) { var pstore = this.get('parentStore'), psRevisions = pstore.revisions; var revisions = this.revisions, storeKey, lock, rev; for (storeKey in records) { if (!records.hasOwnProperty(storeKey)) continue ; if (!(lock = locks[storeKey])) continue; // not locked. rev = psRevisions[storeKey]; if ((rev !== lock) || (revisions[storeKey] > rev)) { this._notifyRecordPropertyChange(parseInt(storeKey, 10)); } } } this.reset(); this.flush(); return this ; }, /** When you are finished working with a chained store, call this method to tear it down. This will also discard any pending changes. @returns {SC.Store} receiver */ destroy: function() { this.discardChanges(); var parentStore = this.get('parentStore'); if (parentStore) parentStore.willDestroyNestedStore(this); sc_super(); return this ; }, /** Resets a store's data hash contents to match its parent. */ reset: function() { // requires a pstore to reset var parentStore = this.get('parentStore'); if (!parentStore) throw SC.Store.NO_PARENT_STORE_ERROR; // inherit data store from parent store. this.dataHashes = SC.beget(parentStore.dataHashes); this.revisions = SC.beget(parentStore.revisions); this.statuses = SC.beget(parentStore.statuses); // beget nested records references this.childRecords = parentStore.childRecords ? SC.beget(parentStore.childRecords) : {}; this.parentRecords = parentStore.parentRecords ? SC.beget(parentStore.parentRecords) : {}; // also, reset private temporary objects this.set('hasChanges', false); this.chainedChanges = this.locks = this.editables = null; this.changelog = null ; // TODO: Notify record instances }, /** @private Chain to parentstore */ refreshQuery: function(query) { var parentStore = this.get('parentStore'); if (parentStore) parentStore.refreshQuery(query); return this ; }, /** Returns the `SC.Error` object associated with a specific record. Delegates the call to the parent store. @param {Number} storeKey The store key of the record. @returns {SC.Error} SC.Error or null if no error associated with the record. */ readError: function(storeKey) { var parentStore = this.get('parentStore'); return parentStore ? parentStore.readError(storeKey) : null; }, /** Returns the `SC.Error` object associated with a specific query. Delegates the call to the parent store. @param {SC.Query} query The SC.Query with which the error is associated. @returns {SC.Error} SC.Error or null if no error associated with the query. */ readQueryError: function(query) { var parentStore = this.get('parentStore'); return parentStore ? parentStore.readQueryError(query) : null; }, /** @private - adapt for nested store */ chainAutonomousStore: function(attrs, newStoreClass) { throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, // .......................................................... // CORE ATTRIBUTE API // // The methods in this layer work on data hashes in the store. They do not // perform any changes that can impact records. Usually you will not need // to use these methods. /** Returns the current edit status of a storekey. May be one of `INHERITED`, `EDITABLE`, and `LOCKED`. Used mostly for unit testing. @param {Number} storeKey the store key @returns {Number} edit status */ storeKeyEditState: function(storeKey) { var editables = this.editables, locks = this.locks; return (editables && editables[storeKey]) ? SC.Store.EDITABLE : (locks && locks[storeKey]) ? SC.Store.LOCKED : SC.Store.INHERITED ; }, /** @private Locks the data hash so that it iterates independently from the parent store. */ _lock: function(storeKey) { var locks = this.locks, rev, editables, pk, pr, path, tup, obj, key; // already locked -- nothing to do if (locks && locks[storeKey]) return this; // create locks if needed if (!locks) locks = this.locks = []; // fixup editables editables = this.editables; if (editables) editables[storeKey] = 0; // if the data hash in the parent store is editable, then clone the hash // for our own use. Otherwise, just copy a reference to the data hash // in the parent store. -- find first non-inherited state var pstore = this.get('parentStore'), editState; while(pstore && (editState=pstore.storeKeyEditState(storeKey)) === SC.Store.INHERITED) { pstore = pstore.get('parentStore'); } if (pstore && editState === SC.Store.EDITABLE) { pk = this.childRecords[storeKey]; if (pk){ // Since this is a nested record we have to actually walk up the parent chain // to get to the root parent and clone that hash. And then reconstruct the // memory space linking. this._lock(pk); pr = this.parentRecords[pk]; if (pr) { path = pr[storeKey]; tup = path ? SC.tupleForPropertyPath(path, this.dataHashes[pk]) : null; if (tup){ obj = tup[0]; key = tup[1]; } this.dataHashes[storeKey] = obj && key ? obj[key] : null; } } else { this.dataHashes[storeKey] = SC.clone(pstore.dataHashes[storeKey], YES); } if (!editables) editables = this.editables = []; editables[storeKey] = 1 ; // mark as editable } else this.dataHashes[storeKey] = pstore.dataHashes[storeKey]; // also copy the status + revision this.statuses[storeKey] = this.statuses[storeKey]; rev = this.revisions[storeKey] = this.revisions[storeKey]; // save a lock and make it not editable locks[storeKey] = rev || 1; return this ; }, /** @private - adds chaining support */ readDataHash: function(storeKey) { if (this.get('lockOnRead')) this._lock(storeKey); return this.dataHashes[storeKey]; }, /** @private - adds chaining support */ readEditableDataHash: function(storeKey) { // lock the data hash if needed this._lock(storeKey); return sc_super(); }, /** @private - adds chaining support - Does not call sc_super because the implementation of the method vary too much. */ writeDataHash: function(storeKey, hash, status) { var locks = this.locks, didLock = NO, rev ; // Update our dataHash and/or status, depending on what was passed in. // Note that if no new hash was passed in, we'll lock the storeKey to // properly fork our dataHash from our parent store. Similarly, if no // status was passed in, we'll save our own copy of the value. if (hash) { this.dataHashes[storeKey] = hash; } else { this._lock(storeKey); didLock = YES; } if (status) { this.statuses[storeKey] = status; } else { if (!didLock) this.statuses[storeKey] = (this.statuses[storeKey] || SC.Record.READY_NEW); } if (!didLock) { rev = this.revisions[storeKey] = this.revisions[storeKey]; // copy ref // make sure we lock if needed. if (!locks) locks = this.locks = []; if (!locks[storeKey]) locks[storeKey] = rev || 1; } // Also note that this hash is now editable. (Even if we locked it, // above, it may not have been marked as editable.) var editables = this.editables; if (!editables) editables = this.editables = []; editables[storeKey] = 1 ; // use number for dense array support // propagate the data to the child records this._updateChildRecordHashes(storeKey, hash, status); return this ; }, /** @private - adds chaining support */ removeDataHash: function(storeKey, status) { // record optimistic lock revision var locks = this.locks; if (!locks) locks = this.locks = []; if (!locks[storeKey]) locks[storeKey] = this.revisions[storeKey] || 1; return sc_super(); }, /** @private - bookkeeping for a single data hash. */ dataHashDidChange: function(storeKeys, rev, statusOnly, key) { // update the revision for storeKey. Use generateStoreKey() because that // guarantees a universally (to this store hierarchy anyway) unique // key value. if (!rev) rev = SC.Store.generateStoreKey(); var isArray, len, idx, storeKey; isArray = SC.typeOf(storeKeys) === SC.T_ARRAY; if (isArray) { len = storeKeys.length; } else { len = 1; storeKey = storeKeys; } var changes = this.get('chainedChanges'); if (!changes) changes = this.chainedChanges = SC.Set.create(); var that = this, didAddChainedChanges = false; for (idx = 0; idx < len; idx++) { if (isArray) storeKey = storeKeys[idx]; this._lock(storeKey); this.revisions[storeKey] = rev; if (!changes.contains(storeKey)) { changes.add(storeKey); didAddChainedChanges = true; } this._notifyRecordPropertyChange(storeKey, statusOnly, key); // notify also the child records this._propagateToChildren(storeKey, function(storeKey){ that.dataHashDidChange(storeKey, null, statusOnly, key); }); } this.setIfChanged('hasChanges', YES); if (didAddChainedChanges) { this.notifyPropertyChange('chainedChanges'); } return this ; }, // .......................................................... // SYNCING CHANGES // /** @private - adapt for nested store */ commitChangesFromNestedStore: function(nestedStore, changes, force) { sc_super(); // save a lock for each store key if it does not have one already // also add each storeKey to my own changes set. var pstore = this.get('parentStore'), psRevisions = pstore.revisions, i; var myLocks = this.locks, myChanges = this.get('chainedChanges'), len, storeKey; if (!myLocks) myLocks = this.locks = []; if (!myChanges) myChanges = this.chainedChanges = SC.Set.create(); len = changes.length ; for(i=0;i<len;i++) { storeKey = changes[i]; if (!myLocks[storeKey]) myLocks[storeKey] = psRevisions[storeKey]||1; myChanges.add(storeKey); } // Finally, mark store as dirty if we have changes this.setIfChanged('hasChanges', myChanges.get('length') > 0); this.notifyPropertyChange('chainedChanges'); this.flush(); return this ; }, // .......................................................... // HIGH-LEVEL RECORD API // /** @private - adapt for nested store */ queryFor: function(recordType, conditions, params) { return this.get('parentStore').queryFor(recordType, conditions, params); }, // .......................................................... // CORE RECORDS API // // The methods in this section can be used to manipulate records without // actually creating record instances. /** @private - adapt for nested store Unlike for the main store, for nested stores if isRefresh=YES, we'll throw an error if the record is dirty. We'll otherwise avoid setting our status because that can disconnect us from upper and/or lower stores. */ retrieveRecords: function(recordTypes, ids, storeKeys, isRefresh, callbacks) { var pstore = this.get('parentStore'), idx, storeKey, len = (!storeKeys) ? ids.length : storeKeys.length, K = SC.Record, status; // Is this a refresh? if (isRefresh) { for(idx=0;idx<len;idx++) { storeKey = !storeKeys ? pstore.storeKeyFor(recordTypes, ids[idx]) : storeKeys[idx]; status = this.peekStatus(storeKey); // We won't allow calling retrieve on a dirty record in a nested store // (although we do allow it in the main store). This is because doing // so would involve writing a unique status, and that would break the // status hierarchy, so even though lower stores would complete the // retrieval, the upper layers would never inherit the new statuses. if (status & K.DIRTY) { throw SC.Store.NESTED_STORE_RETRIEVE_DIRTY_ERROR; } else { // Not dirty? Then abandon any status we had set (to re-establish // any prototype linkage breakage) before asking our parent store to // perform the retrieve. var dataHashes = this.dataHashes, revisions = this.revisions, statuses = this.statuses, editables = this.editables, locks = this.locks; var changed = NO; var statusOnly = NO; if (dataHashes && dataHashes.hasOwnProperty(storeKey)) { delete dataHashes[storeKey]; changed = YES; } if (revisions && revisions.hasOwnProperty(storeKey)) { delete revisions[storeKey]; changed = YES; } if (editables) delete editables[storeKey]; if (locks) delete locks[storeKey]; if (statuses && statuses.hasOwnProperty(storeKey)) { delete statuses[storeKey]; if (!changed) statusOnly = YES; changed = YES; } if (changed) this._notifyRecordPropertyChange(storeKey, statusOnly); } } } return pstore.retrieveRecords(recordTypes, ids, storeKeys, isRefresh, callbacks); }, /** @private - adapt for nested store */ commitRecords: function(recordTypes, ids, storeKeys) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ commitRecord: function(recordType, id, storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ cancelRecords: function(recordTypes, ids, storeKeys) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ cancelRecord: function(recordType, id, storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, // .......................................................... // DATA SOURCE CALLBACKS // // Methods called by the data source on the store /** @private - adapt for nested store */ dataSourceDidCancel: function(storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ dataSourceDidComplete: function(storeKey, dataHash, newId) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ dataSourceDidDestroy: function(storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ dataSourceDidError: function(storeKey, error) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, // .......................................................... // PUSH CHANGES FROM DATA SOURCE // /** @private - adapt for nested store */ pushRetrieve: function(recordType, id, dataHash, storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ pushDestroy: function(recordType, id, storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; }, /** @private - adapt for nested store */ pushError: function(recordType, id, error, storeKey) { if( this.get( "dataSource" ) ) return sc_super(); else throw SC.Store.NESTED_STORE_UNSUPPORTED_ERROR; } }) ;