Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Implements adapter dirtiness hooks

This commit adds the ability for the adapter to
specify which records should be marked dirty when
changes occur, such as when an attribute or
relationship changes.

If you are using the REST adapter, it should
not introduce any breaking changes.

For more information about the rationale
behind this change, please see
BREAKING_CHANGES.md, revision 9.
  • Loading branch information...
commit 5613bde17e7178a9f2c9b503d7e1326ddd4cc9da 1 parent 4b72b73
tomhuda authored Addepairing Station committed
View
253 BREAKING_CHANGES.md
@@ -35,6 +35,259 @@ App.Store = DS.Store.create({
This will remove the exception about changes before revision 2. You will
receive another warning if there is another change.
+## Revision 9
+
+### Adapter-Specified Record Dirtying
+
+One of the goals of Ember Data is to separate application semantics from
+server semantics. For example, you should be able to use a simple
+backend like MongoDB during development. When deploying to production,
+you may want to use a database better equipped to handle scale, like
+PostgreSQL. Despite the many differences between these two database
+technologies, switching between them should not require you to
+rewrite your application.
+
+Ember Data accomplishes this by isolating server-specific code in the
+_adapter_. The adapter is responsible for translating
+application-specific semantics into the appropriate actions for the
+current backend.
+
+To do this, the store must be able to provide as much information as
+possible to the adapter, so that developers can write adapters for
+key-value stores, like Riak and MongoDB; JSON APIs powered by relational
+databases, like Rails talking to PostgreSQL or MySQL; novel transport
+mechanisms, like WebSockets; local databases, like IndexedDB; and
+whatever other persistence schemes may be dreamed up in the future.
+
+Previously, the store would gather up as much information as possible as
+changes happened in the application. Only when the current transaction
+was committed (via `transaction.commit()` or `store.commit()`) would all
+of the information about what changed be bundled up and sent to the
+adapter to be saved.
+
+Remember that the store needs to keep track of the state of records so
+it knows what needs to be saved. In particular, a record loaded into the
+store starts off "clean"—this means that, as far as we know, the copy we
+have on the client is the same as the copy on the server.
+
+A record becomes "dirty" when we change it in some way from the version
+we received from the server.
+
+Obviously, a record becomes dirty if we change an attribute. For
+example, if we change a record's `firstName` attribute from `"Peter"` to
+`"Louis"`, the record is dirty.
+
+But what happens if the _relationship_ between two records changes?
+Which records should be considered dirty?
+
+Consider the case where we have two `App.User` records, `user1` and
+`user2`, and an `App.Post` record, `post`, which represents a blog post.
+We want to change the author of the post from `user1` to `user2`:
+
+```javascript
+post.get('author');
+//=> user1
+post.set('author', user2);
+```
+
+Now, which of these records should we consider dirty? That is, which of
+these records needs to be sent to the adapter to be saved? Just the
+post? The old author, `user1`? The new author, `user2`? All three?
+
+Your answer to this question depends heavily on how you are encoding
+your relationships, which itself depends heavily on the persistence
+strategy you're using.
+
+If you're using a key-value store, like IndexedDB or Riak, your instinct
+is probably to save one-to-many relationships on the parent. For
+example, if you were sending the JSON for `user2` to your server via
+XHR, it would probably look something like this:
+
+```javascript
+{
+ "author": {
+ "name": "Tony",
+ "posts": [1, 2, 3]
+ }
+}
+```
+
+If, on the other hand, you were using a relational database with
+something like Ruby on Rails, your instinct would probably be to encode
+the relationship as a _foreign key_ on the child. In other words, when
+this relationship changed, you would send `post` to the server via a
+JSON representation that looked like this:
+
+```javascript
+{
+ "post": {
+ "title": "Allen Ginsberg on Node.js",
+ "body": "I saw the best minds of my generation destroyed by madness, starving hysterical naked, dragging themselves through the negro streets at dawn looking for an angry fix,\
+angelheaded hipsters burning for the ancient heavenly connection to the starry dynamo in the machinery of night,\
+who poverty and tatters and hollow-eyed and high sat up smoking in the supernatural darkness of cold-water flats floating across the tops of cities contemplating jazz",
+
+ "author_id": 1
+ }
+}
+```
+
+Previously, Ember Data implemented a strategy of picking the "lowest
+common denominator." In other words, because we did not know what
+information the adapter needed, or how it would encode relationships, we
+simply marked **all** records involved in a relationship change (old
+parent, new parent, and child) as dirty. If the adapter did not need to
+send changes to the server for a particular record, it was the
+responsibility of the adapter to immediately release those unneeded records.
+
+This strategy served us well, until we came to the case of **embedded
+records** (which we are working on, but have not yet finished). In this
+case, choosing the "lowest common denominator" strategy and marking all
+records that could _possibly_ be dirty quickly became pathological.
+
+Imagine the case where you are writing a blog app. For legacy reasons,
+your JSON API embeds comments inside of posts, which are themselves
+embedded inside a root blog object. So, for example, when your app asks
+for a particular blog, it receives back a JSON payload that looks like
+this:
+
+```javascript
+{
+ "blog": {
+ "title": "Shit HN Says",
+ "posts": [{
+ "title": "Achieving Roflscale",
+ "comments": [{
+ "body": "Why not choose a more lightweight solution?",
+ "upvotes": 256
+ }]
+ }]
+ }
+}
+```
+
+Let's say we want to upvote the comment:
+
+```javascript
+comment.incrementProperty('upvotes');
+```
+
+In this particular case, we actually need to mark the **entire graph as
+dirty**. And because the store had no visibility into whether or not the
+adapter treated records as embedded, the "lowest common denominator"
+rule that we had used before meant that we would *have to mark entire
+graphs as dirty if a single attribute changed*.
+
+We knew we would be pilloried if we tried to suggest that as a serious
+solution to the problem.
+
+So, after much discussion, we have introduced several new hooks into the
+adapter. These hooks allow the store to ask the adapter about dirtying
+semantics *as soon as changes happen*. This is a fundamental change from
+how the adapter/store relationship worked before.
+
+Previously, the *only* time the store conferred with the adapter was
+when committing a transaction (with the exception of `extractId`, which
+is used to preprocess data payloads from the adapter).
+
+Now, every time an attribute or relationship changes, it is the
+adapter's responsibility to populate the set of records which the store
+should consider dirty.
+
+Here are the hooks available at present:
+
+* `dirtyRecordsForAttributeChange`
+* `dirtyRecordsForBelongsToChange`
+* `dirtyRecordsForHasManyChange`
+
+Each hook gets passed a `dirtySet` that it should populate with records
+to consider dirty, via the `add()` method.
+
+An implementation of the attribute change hook might look like this:
+
+```javascript
+dirtyRecordsForAttributeChange: function(dirtySet, record, attributeName, newValue, oldValue) {
+ // Only mark the record as dirty if the new value
+ // is different from the old value
+ if (newValue !== oldValue) {
+ dirtySet.add(record);
+ }
+}
+```
+
+If you are implementing an adapter with relational semantics, you can
+tell the store to only dirty child records in response to relationship
+changes like this:
+
+```javascript
+dirtyRecordsForBelongsToChange: function(dirtySet, child) {
+ dirtySet.add(child);
+}
+```
+
+Adapters with key-value semantics would simply implement the same hook
+for has-many changes:
+
+```javascript
+dirtyRecordsForHasManyChange: function(dirtySet, parent) {
+ dirtySet.add(parent);
+}
+```
+
+As we explore this brave new world together, you can expect similar
+"runtime hooks" (as opposed to commit-time hooks) to appear in the
+adapter API.
+
+#### TL;DR
+
+Adapters can now tell the store which records become dirty in response
+to changes. If you are using the built-in `DS.RESTAdapter`, these
+changes do not affect you.
+
+### Removal of Dirty Factors
+
+Previously, your adapter could query a record for the reasons it had
+been considered dirty. For example, `record.isDirtyBecause('belongsTo')`
+would return `true` if the adapter was dirty because one of its
+`belongsTo` relationships had changed.
+
+This was necessary because the adapter received all of the records
+associated with a relationship change at once, and had to "reverse
+engineer" what had happened and which records it cared about (see above
+section for more discussion.)
+
+Now, because adapters are notified about changes as they happen, and
+can control which items are marked as dirty, it is no longer necessary
+for adapters to be able to introspect records for _why_ they are dirty;
+de facto, if they are being given to the adapter, it is because the
+adapter told the store it wanted them to be dirty.
+
+Therefore, `DS.Model`'s `isDirtyBecause()` method has been removed. If
+you still need this information in your adapter, it will be your
+responsibility to do any bookkeeping in the
+`dirtyRecordsForAttributeChange` hook described above.
+
+### Single Commit Acknowledgment Hook
+
+Previously, the store took responsibility for tracking *which* things
+were dirty about a record. Only after all "dirty factors" had been
+acknowledged by the adapter as saved would the store transition the
+record back into the "clean" state.
+
+Now, responsibility for transitioning a record is solely the adapter's.
+This architecture lays the groundwork for the ability to have multiple
+adapters; for example, you can imagine having an IndexedDB-based
+write-through cache adapter for offline mode, and a WebSockets-based
+adapter for when your user has an internet connection.
+
+Previous "acknowledgement" API methods on the store have been removed,
+such as `didSaveRecord()` and `didDeleteRecord()`. Now, the only
+acknowledgement an adapter can perform is `didSaveRecord()`, which tells
+the store that all changes to the record have been saved.
+
+If saving changes to a record is not an atomic operation in your
+adapter, keeping track of which more granular operations have occurred
+is now the responsibility of the adapter.
+
## Revision 8
### Making Data Format-Agnostic
View
1  Gemfile
@@ -4,6 +4,7 @@ gem "rake-pipeline", :git => "https://github.com/livingsocial/rake-pipeline.git"
gem "rake-pipeline-web-filters", :git => "https://github.com/wycats/rake-pipeline-web-filters.git"
gem "colored"
gem "uglifier", "~> 1.0.3"
+gem "rake"
group :development do
gem "rack"
View
3  Gemfile.lock
@@ -57,7 +57,7 @@ GEM
multi_json (~> 1.0)
rack (~> 1.2)
rack (1.4.1)
- rake (0.9.2.2)
+ rake (0.9.5)
rb-fsevent (0.9.1)
rest-client (1.6.7)
mime-types (>= 1.16)
@@ -75,6 +75,7 @@ DEPENDENCIES
github_downloads
kicker
rack
+ rake
rake-pipeline!
rake-pipeline-web-filters!
uglifier (~> 1.0.3)
View
32 Rakefile
@@ -25,6 +25,38 @@ def upload_file(uploader, filename, description, file)
end
end
+def git_update
+end
+
+directory "tmp"
+
+file "tmp/ember.js" => "tmp" do
+ cd "tmp" do
+ sh "git clone https://github.com/emberjs/ember.js.git"
+ end
+end
+
+task :update_ember_git => ["tmp/ember.js"] do
+ cd "tmp/ember.js" do
+ sh "git fetch origin"
+ sh "git reset --hard origin/master"
+ end
+end
+
+file "tmp/ember.js/dist/ember.js"
+
+file "packages/ember/lib/main.js" => [:update_ember_git, "tmp/ember.js/dist/ember.js"] do
+ cd "tmp/ember.js" do
+ sh "rake dist"
+ cp "dist/ember.js", "../../packages/ember/lib/main.js"
+ end
+end
+
+namespace :ember do
+ desc "Update Ember.js to master (packages/ember/lib/main.js)"
+ task :update => "packages/ember/lib/main.js"
+end
+
desc "Strip trailing whitespace for JavaScript files in packages"
task :strip_whitespace do
Dir["packages/**/*.js"].each do |name|
View
8 packages/ember-data/lib/adapters/rest_adapter.js
@@ -10,12 +10,6 @@ DS.RESTAdapter = DS.Adapter.extend({
serializer: DS.RESTSerializer,
- shouldCommit: function(record) {
- if (record.isCommittingBecause('attribute') || record.isCommittingBecause('belongsTo')) {
- return true;
- }
- },
-
createRecord: function(store, type, record) {
var root = this.rootForType(type);
@@ -31,6 +25,8 @@ DS.RESTAdapter = DS.Adapter.extend({
});
},
+ dirtyRecordsForHasManyChange: Ember.K,
+
didSaveRecord: function(store, record, hash) {
record.eachAssociation(function(name, meta) {
if (meta.kind === 'belongsTo') {
View
2  packages/ember-data/lib/core.js
@@ -1,3 +1,3 @@
window.DS = Ember.Namespace.create({
- CURRENT_API_REVISION: 8
+ CURRENT_API_REVISION: 9
});
View
50 packages/ember-data/lib/system/adapter.js
@@ -54,10 +54,33 @@ DS.Adapter = Ember.Object.extend(DS._Mappable, {
this._attributesMap = this.createInstanceMapFor('attributes');
+ this._outstandingOperations = new Ember.MapWithDefault({
+ defaultValue: function() { return 0; }
+ });
+
+ this._dependencies = new Ember.MapWithDefault({
+ defaultValue: function() { return new Ember.OrderedSet(); }
+ });
+
this.registerSerializerTransforms(this.constructor, serializer, {});
this.registerSerializerMappings(serializer);
},
+ dirtyRecordsForAttributeChange: function(dirtySet, record, attributeName, newValue, oldValue) {
+ // TODO: Custom equality checking [tomhuda]
+ if (newValue !== oldValue) {
+ dirtySet.add(record);
+ }
+ },
+
+ dirtyRecordsForBelongsToChange: function(dirtySet, child) {
+ dirtySet.add(child);
+ },
+
+ dirtyRecordsForHasManyChange: function(dirtySet, parent) {
+ dirtySet.add(parent);
+ },
+
/**
@private
@@ -212,10 +235,6 @@ DS.Adapter = Ember.Object.extend(DS._Mappable, {
}
},
- shouldCommit: function(record) {
- return true;
- },
-
groupByType: function(enumerable) {
var map = Ember.MapWithDefault.create({
defaultValue: function() { return Ember.OrderedSet.create(); }
@@ -228,24 +247,13 @@ DS.Adapter = Ember.Object.extend(DS._Mappable, {
return map;
},
- commit: function(store, commitDetails) {
- // nº1: determine which records the adapter actually l'cares about
- // nº2: for each relationship, give the adapter an opportunity to mark
- // related records as l'pending
- // nº3: trigger l'save on l'non-pending records
-
- var updated = Ember.OrderedSet.create();
- commitDetails.updated.forEach(function(record) {
- var shouldCommit = this.shouldCommit(record);
-
- if (!shouldCommit) {
- store.didSaveRecord(record);
- } else {
- updated.add(record);
- }
- }, this);
+ processRelationship: function(relationship) {
+ // TODO: Track changes to relationships made after a
+ // materialization request but before the adapter
+ // responds. [tomhuda]
+ },
- commitDetails.updated = updated;
+ commit: function(store, commitDetails) {
this.save(store, commitDetails);
},
View
9 packages/ember-data/lib/system/associations/belongs_to.js
@@ -1,7 +1,9 @@
var get = Ember.get, set = Ember.set,
none = Ember.none;
-var hasAssociation = function(type, options, one) {
+DS.belongsTo = function(type, options) {
+ Ember.assert("The first argument DS.belongsTo must be a model type or string, like DS.belongsTo(App.Person)", !!type && (typeof type === 'string' || DS.Model.detect(type)));
+
options = options || {};
var meta = { type: type, isAssociation: true, options: options, kind: 'belongsTo' };
@@ -23,11 +25,6 @@ var hasAssociation = function(type, options, one) {
}).property('data').meta(meta);
};
-DS.belongsTo = function(type, options) {
- Ember.assert("The type passed to DS.belongsTo must be defined", !!type);
- return hasAssociation(type, options);
-};
-
/**
These observers observe all `belongsTo` relationships on the record. See
`associations/ext` to see how these observers get their dependencies.
View
43 packages/ember-data/lib/system/associations/one_to_many_change.js
@@ -16,12 +16,19 @@ DS.OneToManyChange.create = function(options) {
/** @private */
DS.OneToManyChange.forChildAndParent = function(childClientId, store, options) {
+ // Get the type of the child based on the child's client ID
var childType = store.typeForClientId(childClientId), key;
+ // If the name of the belongsTo side of the relationship is specified,
+ // use that
+ // If the type of the parent is specified, look it up on the child's type
+ // definition.
if (options.parentType) {
key = inverseBelongsToName(options.parentType, childType, options.hasManyName);
- } else {
+ } else if (options.belongsToName) {
key = options.belongsToName;
+ } else {
+ Ember.assert("You must pass either a parentType or belongsToName option to OneToManyChange.forChildAndParent", false);
}
var change = store.relationshipChangeFor(childClientId, key);
@@ -133,18 +140,6 @@ DS.OneToManyChange.prototype = {
store.removeRelationshipChangeFor(childClientId, belongsToName);
- if (child = this.getChild()) {
- child.removeDirtyFactor(belongsToName);
- }
-
- if (oldParent = this.getOldParent()) {
- oldParent.removeDirtyFactor(hasManyName);
- }
-
- if (newParent = this.getNewParent()) {
- newParent.removeDirtyFactor(hasManyName);
- }
-
if (transaction = this.transaction) {
transaction.relationshipBecameClean(this);
}
@@ -279,6 +274,8 @@ DS.OneToManyChange.prototype = {
// infinite loop.
+ var dirtySet = new Ember.OrderedSet();
+
// If there is an `oldParent` and the `oldParent` is different to
// the `newParent`, use the idempotent `removeObject` to ensure
// that the record is no longer in its ManyArray. The `removeObject`
@@ -290,8 +287,13 @@ DS.OneToManyChange.prototype = {
if (oldParent && oldParent !== newParent) {
get(oldParent, hasManyName).removeObject(child);
+ // TODO: This implementation causes a race condition in key-value
+ // stores. The fix involves buffering changes that happen while
+ // a record is loading. A similar fix is required for other parts
+ // of ember-data, and should be done as new infrastructure, not
+ // a one-off hack. [tomhuda]
if (get(oldParent, 'isLoaded')) {
- oldParent.addDirtyFactor(hasManyName);
+ this.store.recordHasManyDidChange(dirtySet, oldParent, this);
}
}
@@ -303,7 +305,7 @@ DS.OneToManyChange.prototype = {
get(newParent, hasManyName).addObject(child);
if (get(newParent, 'isLoaded')) {
- newParent.addDirtyFactor(hasManyName);
+ this.store.recordHasManyDidChange(dirtySet, newParent, this);
}
}
@@ -315,11 +317,13 @@ DS.OneToManyChange.prototype = {
set(child, belongsToName, newParent);
}
- if (get(child, 'isLoaded')) {
- child.addDirtyFactor(belongsToName);
- }
+ this.store.recordBelongsToDidChange(dirtySet, child, this);
}
+ dirtySet.forEach(function(record) {
+ record.adapterDidDirty();
+ });
+
// If this change is later reversed (A->B followed by B->A),
// we will need to remove the child from this parent. Save
// it off as `lastParent` so we can do that.
@@ -333,9 +337,6 @@ DS.OneToManyChange.prototype = {
var hasManyName = this.getHasManyName();
var oldParent, newParent, child;
- if (oldParent = this.getOldParent()) { oldParent.removeInFlightDirtyFactor(hasManyName); }
- if (newParent = this.getNewParent()) { newParent.removeInFlightDirtyFactor(hasManyName); }
- if (child = this.getChild()) { child.removeInFlightDirtyFactor(belongsToName); }
this.destroy();
},
View
8 packages/ember-data/lib/system/model/attributes.js
@@ -47,12 +47,14 @@ DS.attr = function(type, options) {
options: options
};
- return Ember.computed(function(key, value) {
+ return Ember.computed(function(key, value, oldValue) {
var data;
- if (arguments.length === 2) {
+ if (arguments.length > 1) {
+ // TODO: If there is a cached oldValue, use it [tomhuda]
+ oldValue = get(this, 'data.attributes')[key];
Ember.assert("You may not set `id` as an attribute on your model. Please remove any lines that look like: `id: DS.attr('<type>')` from " + this.toString(), key !== 'id');
- this.setProperty(key, value);
+ this.setProperty(key, value, oldValue);
} else {
value = getAttr(this, options, key);
}
View
135 packages/ember-data/lib/system/model/model.js
@@ -74,9 +74,6 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
setup: function() {
this._relationshipChanges = {};
- this._dirtyFactors = Ember.OrderedSet.create();
- this._inFlightDirtyFactors = Ember.OrderedSet.create();
- this._dirtyReasons = { hasMany: 0, belongsTo: 0, attribute: 0 };
},
willDestroy: function() {
@@ -106,8 +103,8 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
this.send('didChangeData');
},
- setProperty: function(key, value) {
- this.send('setProperty', { key: key, value: value });
+ setProperty: function(key, value, oldValue) {
+ this.send('setProperty', { key: key, value: value, oldValue: oldValue });
},
deleteRecord: function() {
@@ -143,6 +140,12 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
attributes[name] = get(this, name);
}, this);
+ this.send('didCommit');
+ this.updateRecordArraysLater();
+ },
+
+ adapterDidDirty: function() {
+ this.send('becomeDirty');
this.updateRecordArraysLater();
},
@@ -208,62 +211,6 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
this._data.belongsTo[name] = id;
},
- // DIRTINESS FACTORS
- //
- // These methods allow the manipulation of various "dirtiness factors" on
- // the current record. A dirtiness factor can be:
- //
- // * the name of a dirty attribute
- // * the name of a dirty relationship
- // * @created, if the record was created
- // * @deleted, if the record was deleted
- //
- // This allows adapters to acknowledge updates to any of the dirtiness
- // factors one at a time, and keeps the bookkeeping for full acknowledgement
- // in the record itself.
-
- addDirtyFactor: function(name) {
- var dirtyFactors = this._dirtyFactors, becameDirty;
- if (dirtyFactors.has(name)) { return; }
-
- if (this._dirtyFactors.isEmpty()) { becameDirty = true; }
-
- this._addDirtyFactor(name);
-
- if (becameDirty && name !== '@created' && name !== '@deleted') {
- this.send('becameDirty');
- }
- },
-
- _addDirtyFactor: function(name) {
- this._dirtyFactors.add(name);
-
- var reason = get(this.constructor, 'fields').get(name);
- this._dirtyReasons[reason]++;
- },
-
- removeDirtyFactor: function(name) {
- var dirtyFactors = this._dirtyFactors, becameClean = true;
- if (!dirtyFactors.has(name)) { return; }
-
- this._dirtyFactors.remove(name);
-
- var reason = get(this.constructor, 'fields').get(name);
- this._dirtyReasons[reason]--;
-
- if (!this._dirtyFactors.isEmpty()) { becameClean = false; }
-
- if (becameClean && name !== '@created' && name !== '@deleted') {
- this.send('becameClean');
- }
- },
-
- removeDirtyFactors: function() {
- this._dirtyFactors.clear();
- this._dirtyReasons = { hasMany: 0, belongsTo: 0, attribute: 0 };
- this.send('becameClean');
- },
-
rollback: function() {
this.setup();
this.send('becameClean');
@@ -273,14 +220,6 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
});
},
- isDirtyBecause: function(reason) {
- return this._dirtyReasons[reason] > 0;
- },
-
- isCommittingBecause: function(reason) {
- return this._inFlightDirtyReasons[reason] > 0;
- },
-
/**
@private
@@ -312,55 +251,11 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
},
becameInFlight: function() {
- this._inFlightDirtyFactors = this._dirtyFactors.copy();
- this._inFlightDirtyReasons = this._dirtyReasons;
- this._dirtyFactors.clear();
- this._dirtyReasons = { hasMany: 0, belongsTo: 0, attribute: 0 };
- },
-
- restoreDirtyFactors: function() {
- this._inFlightDirtyFactors.forEach(function(factor) {
- this._addDirtyFactor(factor);
- }, this);
-
- this._inFlightDirtyFactors.clear();
- this._inFlightDirtyReasons = null;
- },
-
- removeInFlightDirtyFactor: function(name) {
- // It is possible for a record to have been materialized
- // or loaded after the transaction was committed. This
- // can happen with relationship changes involving
- // unmaterialized records that subsequently load.
- //
- // XXX If a record is materialized after it was involved
- // while it is involved in a relationship change, update
- // it to be in the same state as if it had already been
- // materialized.
- //
- // For now, this means that we have a blind spot where
- // a record was loaded and its relationships changed
- // while the adapter is in the middle of persisting
- // a relationship change involving it.
- if (this._inFlightDirtyFactors.has(name)) {
- this._inFlightDirtyFactors.remove(name);
- if (this._inFlightDirtyFactors.isEmpty()) {
- this._inFlightDirtyReasons = null;
- this.send('didCommit');
- }
- }
- },
-
- removeInFlightDirtyFactorsForAttributes: function() {
- this.eachAttribute(function(name) {
- this.removeInFlightDirtyFactor(name);
- }, this);
},
// FOR USE DURING COMMIT PROCESS
adapterDidUpdateAttribute: function(attributeName, value) {
- this.removeInFlightDirtyFactor(attributeName);
// If a value is passed in, update the internal attributes and clear
// the attribute cache so it picks up the new value. Otherwise,
@@ -378,8 +273,6 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
},
adapterDidUpdateHasMany: function(name) {
- this.removeInFlightDirtyFactor(name);
-
var cachedValue = this.cacheFor(name),
hasMany = get(this, 'data').hasMany,
store = get(this, 'store');
@@ -405,18 +298,6 @@ DS.Model = Ember.Object.extend(Ember.Evented, {
this.updateRecordArraysLater();
},
- adapterDidDelete: function() {
- this.removeInFlightDirtyFactor('@deleted');
-
- this.updateRecordArraysLater();
- },
-
- adapterDidCreate: function() {
- this.removeInFlightDirtyFactor('@created');
-
- this.updateRecordArraysLater();
- },
-
adapterDidInvalidate: function(errors) {
this.send('becameInvalid', errors);
},
View
48 packages/ember-data/lib/system/model/states.js
@@ -180,18 +180,13 @@ var didChangeData = function(manager) {
};
var setProperty = function(manager, context) {
- var value = context.value,
+ var record = get(manager, 'record'),
+ store = get(record, 'store'),
key = context.key,
- record = get(manager, 'record'),
- adapterValue = get(record, 'data.attributes')[key];
+ oldValue = context.oldValue,
+ newValue = context.value;
- if (value === adapterValue) {
- record.removeDirtyFactor(key);
- } else {
- record.addDirtyFactor(key);
- }
-
- updateRecordArrays(manager);
+ store.recordAttributeDidChange(record, key, newValue, oldValue);
};
// Whenever a property is set, recompute all dependent filters
@@ -284,6 +279,8 @@ var DirtyState = DS.State.extend({
// EVENTS
setProperty: setProperty,
+ becomeDirty: Ember.K,
+
willCommit: function(manager) {
manager.transitionTo('inFlight');
},
@@ -327,7 +324,6 @@ var DirtyState = DS.State.extend({
var dirtyType = get(this, 'dirtyType'),
record = get(manager, 'record');
- // create inFlightDirtyFactors
record.becameInFlight();
record.withTransaction(function (t) {
@@ -353,8 +349,6 @@ var DirtyState = DS.State.extend({
set(record, 'errors', errors);
- record.restoreDirtyFactors();
-
manager.transitionTo('invalid');
manager.send('invokeLifecycleCallbacks');
},
@@ -400,6 +394,8 @@ var DirtyState = DS.State.extend({
setProperty(manager, context);
},
+ becomeDirty: Ember.K,
+
rollback: function(manager) {
manager.send('becameValid');
manager.send('rollback');
@@ -424,18 +420,7 @@ var createdState = DirtyState.create({
dirtyType: 'created',
// FLAGS
- isNew: true,
-
- // TRANSITIONS
- setup: function(manager) {
- var record = get(manager, 'record');
- record.addDirtyFactor('@created');
- },
-
- exit: function(manager) {
- var record = get(manager, 'record');
- record.removeDirtyFactor('@created');
- }
+ isNew: true
});
var updatedState = DirtyState.create({
@@ -544,7 +529,7 @@ var states = {
didChangeData: didChangeData,
loadedData: didChangeData,
- becameDirty: function(manager) {
+ becomeDirty: function(manager) {
manager.transitionTo('updated');
},
@@ -617,17 +602,9 @@ var states = {
var record = get(manager, 'record'),
store = get(record, 'store');
- record.addDirtyFactor('@deleted');
-
store.removeFromRecordArrays(record);
},
- exit: function(manager) {
- var record = get(manager, 'record');
-
- record.removeDirtyFactor('@deleted');
- },
-
// SUBSTATES
// When a record is deleted, it enters the `start`
@@ -652,6 +629,8 @@ var states = {
get(manager, 'record').rollback();
},
+ becomeDirty: Ember.K,
+
becameClean: function(manager) {
var record = get(manager, 'record');
@@ -675,7 +654,6 @@ var states = {
enter: function(manager) {
var record = get(manager, 'record');
- // create inFlightDirtyFactors
record.becameInFlight();
record.withTransaction(function (t) {
View
69 packages/ember-data/lib/system/store.js
@@ -865,18 +865,9 @@ DS.Store = Ember.Object.extend(DS._Mappable, {
@param {Object} data optional data (see above)
*/
didSaveRecord: function(record, data) {
- if (get(record, 'isNew')) {
- this.didCreateRecord(record);
- } else if (get(record, 'isDeleted')) {
- this.didDeleteRecord(record);
- }
+ record.adapterDidCommit();
if (data) {
- // We're about to replace all existing attributes and relationships
- // because we have new data, so clear out any remaining unacknowledged
- // changes.
-
- record.removeInFlightDirtyFactorsForAttributes();
this.updateId(record, data);
this.updateRecordData(record, data);
} else {
@@ -907,32 +898,6 @@ DS.Store = Ember.Object.extend(DS._Mappable, {
},
/**
- TODO: Do we need this?
- */
- didDeleteRecord: function(record) {
- record.adapterDidDelete();
- },
-
- /**
- This method allows the adapter to acknowledge just that
- a record was created, but defer acknowledging specific
- attributes or relationships.
-
- This is most useful for adapters that have a multi-step
- creation process (first, create the record on the backend,
- then make a separate request to add the attributes).
-
- When acknowledging a newly created record using a more
- fine-grained approach, you *must* call `didCreateRecord`,
- or the record will remain in in-flight limbo forever.
-
- @param {DS.Model} record
- */
- didCreateRecord: function(record) {
- record.adapterDidCreate();
- },
-
- /**
This method allows the adapter to specify that a record
could not be saved because it had backend-supplied validation
errors.
@@ -1619,6 +1584,38 @@ DS.Store = Ember.Object.extend(DS._Mappable, {
if (adapter) { return adapter; }
return this.get('_adapter');
+ },
+
+ // ..............................
+ // . RECORD CHANGE NOTIFICATION .
+ // ..............................
+ recordAttributeDidChange: function(record, attributeName, newValue, oldValue) {
+ var dirtySet = new Ember.OrderedSet(),
+ adapter = this.adapterForType(record.constructor);
+
+ if (adapter.dirtyRecordsForAttributeChange) {
+ adapter.dirtyRecordsForAttributeChange(dirtySet, record, attributeName, newValue, oldValue);
+ }
+
+ dirtySet.forEach(function(record) {
+ record.adapterDidDirty();
+ });
+ },
+
+ recordBelongsToDidChange: function(dirtySet, child, relationship) {
+ var adapter = this.adapterForType(child.constructor);
+
+ if (adapter.dirtyRecordsForBelongsToChange) {
+ adapter.dirtyRecordsForBelongsToChange(dirtySet, child, relationship);
+ }
+ },
+
+ recordHasManyDidChange: function(dirtySet, parent, relationship) {
+ var adapter = this.adapterForType(parent.constructor);
+
+ if (adapter.dirtyRecordsForHasManyChange) {
+ adapter.dirtyRecordsForHasManyChange(dirtySet, parent, relationship);
+ }
}
});
View
14 packages/ember-data/lib/system/transaction.js
@@ -1,13 +1,6 @@
var get = Ember.get, set = Ember.set, fmt = Ember.String.fmt,
removeObject = Ember.EnumerableUtils.removeObject, forEach = Ember.EnumerableUtils.forEach;
-var RelationshipLink = function(parent, child) {
- this.oldParent = parent;
- this.child = child;
-};
-
-
-
/**
A transaction allows you to collect multiple records into a unit of work
that can be committed or rolled back as a group.
@@ -208,6 +201,13 @@ DS.Transaction = Ember.Object.extend({
if (adapter && adapter.commit) { adapter.commit(store, commitDetails); }
else { throw fmt("Adapter is either null or does not implement `commit` method", this); }
}
+
+ // Once we've committed the transaction, there is no need to
+ // keep the OneToManyChanges around. Destroy them so they
+ // can be garbage collected.
+ relationships.forEach(function(relationship) {
+ relationship.destroy();
+ });
},
/**
View
7 packages/ember-data/tests/integration/associations_test.js
@@ -183,12 +183,7 @@ test("An adapter can materialize a hash and get it back later in a findAssociati
test("When adding a child to a parent, then commit, the parent should come back to a clean state", function() {
expect(2);
- adapter.shouldCommit = function(record) {
- //behaves like DS.RESTAdapter, a parent record should not be commited when adding a child
- if (record.isCommittingBecause('attribute') || record.isCommittingBecause('belongsTo')) {
- return true;
- }
- };
+ adapter.dirtyRecordsForHasManyChange = Ember.K;
var didSaveRecord = function(store, record, hash) {
record.eachAssociation(function(name, meta) {
View
123 packages/ember-data/tests/integration/dirtiness_test.js
@@ -0,0 +1,123 @@
+var store, adapter;
+var Person;
+
+module("Attribute Changes and Dirtiness", {
+ setup: function() {
+ adapter = DS.Adapter.create();
+
+ store = DS.Store.create({
+ adapter: adapter
+ });
+
+ Person = DS.Model.extend({
+ firstName: DS.attr('string')
+ });
+ }
+});
+
+test("By default, if a record's attribute is changed, it becomes dirty", function() {
+ store.load(Person, { id: 1, firstName: "Yehuda" });
+ var wycats = store.find(Person, 1);
+
+ wycats.set('firstName', "Brohuda");
+
+ ok(wycats.get('isDirty'), "record has become dirty");
+});
+
+test("By default, a newly created record is dirty", function() {
+ var wycats = store.createRecord(Person);
+
+ ok(wycats.get('isDirty'), "record is dirty");
+});
+
+test("By default, changing the relationship between two records does not cause them to become dirty", function() {
+ adapter.dirtyRecordsForHasManyChange = Ember.K;
+ adapter.dirtyRecordsForBelongsToChange = Ember.K;
+
+ var Post = DS.Model.extend();
+
+ var Comment = DS.Model.extend({
+ post: DS.belongsTo(Post)
+ });
+
+ Post.reopen({
+ comments: DS.hasMany(Comment)
+ });
+
+ store.load(Post, { id: 1, comments: [1] });
+ store.load(Comment, { id: 1, post: 1 });
+
+ var post = store.find(Post, 1);
+ var comment = store.find(Comment, 1);
+
+ comment.set('post', null);
+
+ ok(!post.get('isDirty'), "post should not be dirty");
+ ok(!comment.get('isDirty'), "comment should not be dirty");
+});
+
+test("If dirtyRecordsForAttributeChange does not add the record to the dirtyRecords set, it does not become dirty", function() {
+ store.load(Person, { id: 1, firstName: "Yehuda" });
+ var wycats = store.find(Person, 1);
+
+ adapter.dirtyRecordsForAttributeChange = function(dirtyRecords, changedRecord, attributeName) {
+ equal(changedRecord, wycats, "changed record is passed to hook");
+ equal(attributeName, "firstName", "attribute name is passed to hook");
+ };
+
+ wycats.set('firstName', "Brohuda");
+
+ ok(!wycats.get('isDirty'), "the record is not dirty despite attribute change");
+});
+
+test("If dirtyRecordsForAttributeChange adds the record to the dirtyRecords set, it becomes dirty", function() {
+ store.load(Person, { id: 1, firstName: "Yehuda" });
+ var wycats = store.find(Person, 1);
+
+ adapter.dirtyRecordsForAttributeChange = function(dirtyRecords, changedRecord, attributeName) {
+ equal(changedRecord, wycats, "changed record is passed to hook");
+ equal(attributeName, "firstName", "attribute name is passed to hook");
+ dirtyRecords.add(changedRecord);
+ };
+
+ wycats.set('firstName', "Brohuda");
+
+ ok(wycats.get('isDirty'), "the record is dirty after attribute change");
+});
+
+test("If dirtyRecordsForAttributeChange adds a different record than the changed record to the dirtyRecords set, the different record becomes dirty", function() {
+ store.load(Person, { id: 1, firstName: "Yehuda" });
+ store.load(Person, { id: 2, firstName: "Tom" });
+ var wycats = store.find(Person, 1);
+ var tomdale = store.find(Person, 2);
+
+ adapter.dirtyRecordsForAttributeChange = function(dirtyRecords, changedRecord, attributeName) {
+ equal(changedRecord, wycats, "changed record is passed to hook");
+ equal(attributeName, "firstName", "attribute name is passed to hook");
+ dirtyRecords.add(tomdale);
+ };
+
+ wycats.set('firstName', "Brohuda");
+
+ ok(tomdale.get('isDirty'), "the record is dirty after attribute change");
+ ok(!wycats.get('isDirty'), "the record is not dirty after attribute change");
+});
+
+test("If dirtyRecordsForAttributeChange adds two records to the dirtyRecords set, both become dirty", function() {
+ store.load(Person, { id: 1, firstName: "Yehuda" });
+ store.load(Person, { id: 2, firstName: "Tom" });
+ var wycats = store.find(Person, 1);
+ var tomdale = store.find(Person, 2);
+
+ adapter.dirtyRecordsForAttributeChange = function(dirtyRecords, changedRecord, attributeName) {
+ equal(changedRecord, wycats, "changed record is passed to hook");
+ equal(attributeName, "firstName", "attribute name is passed to hook");
+ dirtyRecords.add(tomdale);
+ dirtyRecords.add(wycats);
+ };
+
+ wycats.set('firstName', "Brohuda");
+
+ ok(tomdale.get('isDirty'), "the record is dirty after attribute change");
+ ok(wycats.get('isDirty'), "the record is dirty after attribute change");
+});
View
1  packages/ember-data/tests/unit/fixture_adapter_test.js
@@ -158,6 +158,7 @@ test("should follow isUpdating semantics", function() {
result.addObserver('isUpdating', function() {
start();
+ clearTimeout(timer);
equal(get(result, 'isUpdating'), false, "isUpdating is set when it shouldn't be");
});
View
151 packages/ember-data/tests/unit/model_test.js
@@ -224,157 +224,6 @@ test("when a method is invoked from an event with the same name the arguments ar
equal( eventMethodArgs[1], 2);
});
-// Dirty Factors
-
-var Model, Post, Comment, events;
-
-module("DS.Model - Dirty Factors", {
- setup: function() {
- events = [];
-
- Model = DS.Model.extend({
- send: function() {
- events.push([this, Array.prototype.slice.call(arguments)]);
- }
- });
-
- Post = Model.extend();
- Comment = Model.extend();
-
- Person = Model.extend({
- firstName: DS.attr('string'),
- lastName: DS.attr('string'),
-
- post: DS.belongsTo(Post),
- comment: DS.belongsTo(Comment),
-
- posts: DS.hasMany(Post),
- comments: DS.hasMany(Comment)
- });
- },
-
- teardown: function() {
-
- }
-});
-
-test("Calling addDirtyFactor() with an attribute should cause the record to become dirty", function() {
- var record = Person._create();
-
- record.addDirtyFactor('firstName');
-
- equal(events.length, 1, "one event was sent");
- strictEqual(events[0][0], record, "send should be called with the record");
- deepEqual(events[0][1], ['becameDirty'], "becameDirty event was sent");
-
- ok(record.isDirtyBecause('attribute'), "record is dirty because an attribute changed");
-
- // Make sure adding the same dirty factor again does
- // not trigger more dirtiness.
- record.addDirtyFactor('firstName');
-
- equal(events.length, 1, "one event was sent");
-
- record.addDirtyFactor('lastName');
-
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('lastName');
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('firstName');
- equal(events.length, 2, "two events were sent");
-
- strictEqual(events[1][0], record, "send should be called with the record");
- deepEqual(events[1][1], ['becameClean'], "becameClean event was sent");
-
- ok(!record.isDirtyBecause('attribute'), "record is no longer dirty because an attribute changed");
-
- // extra removals do not make the record ULTRA-CLEAN
- record.removeDirtyFactor('firstName');
-
- record.addDirtyFactor('firstName');
- ok(record.isDirtyBecause('attribute'), "record is dirty because an attribute changed");
-});
-
-test("Calling addDirtyFactor() with an belongsTo should cause the record to become dirty", function() {
- var record = Person._create();
-
- record.addDirtyFactor('post');
-
- equal(events.length, 1, "one event was sent");
- strictEqual(events[0][0], record, "send should be called with the record");
- deepEqual(events[0][1], ['becameDirty'], "becameDirty event was sent");
-
- ok(record.isDirtyBecause('belongsTo'), "record is dirty because belongsTo changed");
-
- // Make sure adding the same dirty factor again does
- // not trigger more dirtiness.
- record.addDirtyFactor('post');
-
- equal(events.length, 1, "one event was sent");
-
- record.addDirtyFactor('comment');
-
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('comment');
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('post');
- equal(events.length, 2, "two events were sent");
-
- strictEqual(events[1][0], record, "send should be called with the record");
- deepEqual(events[1][1], ['becameClean'], "becameClean event was sent");
-
- ok(!record.isDirtyBecause('belongsTo'), "record is no longer dirty because a belongsTo changed");
-
- // extra removals do not make the record ULTRA-CLEAN
- record.removeDirtyFactor('post');
-
- record.addDirtyFactor('post');
- ok(record.isDirtyBecause('belongsTo'), "record is dirty because a belongsTo changed");
-});
-
-test("Calling addDirtyFactor() with a hasMany should cause the record to become dirty", function() {
- var record = Person._create();
-
- record.addDirtyFactor('posts');
-
- equal(events.length, 1, "one event was sent");
- strictEqual(events[0][0], record, "send should be called with the record");
- deepEqual(events[0][1], ['becameDirty'], "becameDirty event was sent");
-
- ok(record.isDirtyBecause('hasMany'), "record is dirty because hasMany changed");
-
- // Make sure adding the same dirty factor again does
- // not trigger more dirtiness.
- record.addDirtyFactor('posts');
-
- equal(events.length, 1, "one event was sent");
-
- record.addDirtyFactor('comments');
-
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('comments');
- equal(events.length, 1, "one event was sent");
-
- record.removeDirtyFactor('posts');
- equal(events.length, 2, "two events were sent");
-
- strictEqual(events[1][0], record, "send should be called with the record");
- deepEqual(events[1][1], ['becameClean'], "becameClean event was sent");
-
- ok(!record.isDirtyBecause('hasMany'), "record is no longer dirty because a hasMany changed");
-
- // extra removals do not make the record ULTRA-CLEAN
- record.removeDirtyFactor('posts');
-
- record.addDirtyFactor('posts');
- ok(record.isDirtyBecause('hasMany'), "record is dirty because hasMany changed");
-});
-
var converts = function(type, provided, expected) {
var testStore = DS.Store.create();
View
32 packages/ember-data/tests/unit/rest_adapter_test.js
@@ -67,13 +67,13 @@ module("the REST adapter", {
},
teardown: function() {
- adapter.destroy();
- store.destroy();
-
if (person) {
person.destroy();
person = null;
}
+
+ adapter.destroy();
+ store.destroy();
}
});
@@ -271,32 +271,6 @@ test("deleting a person makes a DELETE to /people/:id", function() {
expectState('deleted');
});
-test("add/commit/delete/commit a person from a group, group lifecycle", function() {
- store.load(Group, { id: 1, name: "Whiskey drinkers"});
- store.load(Person, { id: 1, name: "Tom Dale"});
-
- var
- person = store.find(Person, 1),
- group = store.find(Group, 1);
-
- group.get('people').pushObject(person);
- equal(group.get('isDirty'), true, "The group should be dirty after adding child");
- store.commit();
- equal(group.get('isSaving'), true, "The group should be saving");
- ajaxHash.success();
- equal(group.get('isDirty'), false, "The record should no longer be dirty");
- equal(group.get('isSaving'), false, "The record should no longer be saving");
-
- person.deleteRecord();
- equal(group.get('isDirty'), true, "The group should be dirty after deleting a child");
- store.commit();
- equal(group.get('isSaving'), true, "The group should be saving");
- ajaxHash.success();
-
- equal(group.get('isDirty'), false, "The group should no longer be dirty");
- equal(group.get('isSaving'), false, "The group should no longer be saving");
-});
-
test("singular deletes can sideload data", function() {
adapter.mappings = {
groups: Group
View
2  packages/ember-data/tests/unit/transaction_test.js
@@ -79,7 +79,7 @@ test("a record is removed from a transaction after the records become clean", fu
createRecord: function(store, type, record) {
createCalls++;
- store.didCreateRecord(record, { id: 1 });
+ store.didSaveRecord(record, { id: 1 });
},
updateRecords: function() {
View
1,758 packages/ember/lib/main.js
@@ -1,5 +1,5 @@
-// Version: v1.0.pre-228-g5ee55e5
-// Last commit: 5ee55e5 (2012-10-19 20:59:34 -0700)
+// Version: v1.0.0-pre.2-51-gbc60262
+// Last commit: bc60262 (2012-11-26 10:41:17 -0800)
(function() {
@@ -140,11 +140,43 @@ window.ember_deprecateFunc = Ember.deprecateFunc("ember_deprecateFunc is deprec
})();
-// Version: v1.0.pre-235-gac54388
-// Last commit: ac54388 (2012-10-19 23:52:56 -0700)
+// Version: v1.0.0-pre.2-51-gbc60262
+// Last commit: bc60262 (2012-11-26 10:41:17 -0800)
(function() {
+var define, requireModule;
+
+(function() {
+ var registry = {}, seen = {};
+
+ define = function(name, deps, callback) {
+ registry[name] = { deps: deps, callback: callback };
+ };
+
+ requireModule = function(name) {
+ if (seen[name]) { return seen[name]; }
+ seen[name] = {};
+
+ var mod = registry[name],
+ deps = mod.deps,
+ callback = mod.callback,
+ reified = [],
+ exports;
+
+ for (var i=0, l=deps.length; i<l; i++) {
+ if (deps[i] === 'exports') {
+ reified.push(exports = {});
+ } else {
+ reified.push(requireModule(deps[i]));
+ }
+ }
+
+ var value = callback.apply(this, reified);
+ return seen[name] = exports || value;
+ };
+})();
+(function() {
/*globals Em:true ENV */
/**
@@ -170,7 +202,7 @@ window.ember_deprecateFunc = Ember.deprecateFunc("ember_deprecateFunc is deprec
@class Ember
@static
- @version 1.0.pre
+ @version 1.0.0-pre.2
*/
if ('undefined' === typeof Ember) {
@@ -197,10 +229,10 @@ Ember.toString = function() { return "Ember"; };
/**
@property VERSION
@type String
- @default '1.0.pre'
+ @default '1.0.0-pre.2'
@final
*/
-Ember.VERSION = '1.0.pre';
+Ember.VERSION = '1.0.0-pre.2';
/**
Standard environmental variables. You can define these in a global `ENV`
@@ -284,6 +316,15 @@ if ('undefined' === typeof ember_deprecateFunc) {
exports.ember_deprecateFunc = function(_, func) { return func; };
}
+/**
+ Previously we used `Ember.$.uuid`, however `$.uuid` has been removed from jQuery master.
+ We'll just bootstrap our own uuid now.
+
+ @property uuid
+ @type Number
+ @private
+*/
+Ember.uuid = 0;
// ..........................................................
// LOGGER
@@ -950,6 +991,10 @@ if (isDefinePropertySimulated) {
// jQuery.extend() by having a property that fails
// hasOwnProperty check.
Meta.prototype.__preventPlainObject__ = true;
+
+ // Without non-enumerable properties, meta objects will be output in JSON
+ // unless explicitly suppressed
+ Meta.prototype.toJSON = function () { };
}
/**
@@ -2028,78 +2073,78 @@ Ember.defineProperty = function(obj, keyName, desc, data, meta) {
var AFTER_OBSERVERS = ':change';
var BEFORE_OBSERVERS = ':before';
+
var guidFor = Ember.guidFor;
var deferred = 0;
-var array_Slice = [].slice;
-
-var ObserverSet = function () {
- this.targetSet = {};
-};
-ObserverSet.prototype.add = function (target, path) {
- var targetSet = this.targetSet,
- targetGuid = Ember.guidFor(target),
- pathSet = targetSet[targetGuid];
- if (!pathSet) {
- targetSet[targetGuid] = pathSet = {};
- }
- if (pathSet[path]) {
- return false;
- } else {
- return pathSet[path] = true;
- }
-};
-ObserverSet.prototype.clear = function () {
- this.targetSet = {};
-};
-var DeferredEventQueue = function() {
- this.targetSet = {};
- this.queue = [];
-};
+/*
+ this.observerSet = {
+ [senderGuid]: { // variable name: `keySet`
+ [keyName]: listIndex
+ }
+ },
+ this.observers = [
+ {
+ sender: obj,
+ keyName: keyName,
+ eventName: eventName,
+ listeners: {
+ [targetGuid]: { // variable name: `actionSet`
+ [methodGuid]: { // variable name: `action`
+ target: [Object object],
+ method: [Function function]
+ }
+ }
+ }
+ },
+ ...
+ ]
+*/
+function ObserverSet() {
+ this.clear();
+}
-DeferredEventQueue.prototype.push = function(target, eventName, keyName) {
- var targetSet = this.targetSet,
- queue = this.queue,
- targetGuid = Ember.guidFor(target),
- eventNameSet = targetSet[targetGuid],
- index;
+ObserverSet.prototype.add = function(sender, keyName, eventName) {
+ var observerSet = this.observerSet,
+ observers = this.observers,
+ senderGuid = Ember.guidFor(sender),
+ keySet = observerSet[senderGuid],
+ index;
- if (!eventNameSet) {
- targetSet[targetGuid] = eventNameSet = {};
+ if (!keySet) {
+ observerSet[senderGuid] = keySet = {};
}
- index = eventNameSet[eventName];
+ index = keySet[keyName];
if (index === undefined) {
- eventNameSet[eventName] = queue.push(Ember.deferEvent(target, eventName, [target, keyName])) - 1;
- } else {
- queue[index] = Ember.deferEvent(target, eventName, [target, keyName]);
- }
+ index = observers.push({
+ sender: sender,
+ keyName: keyName,
+ eventName: eventName,
+ listeners: []
+ }) - 1;
+ keySet[keyName] = index;
+ }
+ return observers[index].listeners;
};
-DeferredEventQueue.prototype.flush = function() {
- var queue = this.queue;
- this.queue = [];
- this.targetSet = {};
- for (var i=0, len=queue.length; i < len; ++i) {
- queue[i]();
+ObserverSet.prototype.flush = function() {
+ var observers = this.observers, i, len, observer, sender;
+ this.clear();
+ for (i=0, len=observers.length; i < len; ++i) {
+ observer = observers[i];
+ sender = observer.sender;
+ if (sender.isDestroyed) { continue; }
+ Ember.sendEvent(sender, observer.eventName, [sender, observer.keyName], observer.listeners);
}
};
-var queue = new DeferredEventQueue(), beforeObserverSet = new ObserverSet();
-
-function notifyObservers(obj, eventName, keyName, forceNotification) {
- if (deferred && !forceNotification) {
- queue.push(obj, eventName, keyName);
- } else {
- Ember.sendEvent(obj, eventName, [obj, keyName]);
- }
-}
-
-function flushObserverQueue() {
- beforeObserverSet.clear();
+ObserverSet.prototype.clear = function() {
+ this.observerSet = {};
+ this.observers = [];
+};
- queue.flush();
-}
+var beforeObserverSet = new ObserverSet(), observerSet = new ObserverSet();
/**
@method beginPropertyChanges
@@ -2107,7 +2152,6 @@ function flushObserverQueue() {
*/
Ember.beginPropertyChanges = function() {
deferred++;
- return this;
};
/**
@@ -2115,7 +2159,10 @@ Ember.beginPropertyChanges = function() {
*/
Ember.endPropertyChanges = function() {
deferred--;
- if (deferred<=0) flushObserverQueue();
+ if (deferred<=0) {
+ beforeObserverSet.clear();
+ observerSet.flush();
+ }
};
/**
@@ -2252,29 +2299,31 @@ Ember.removeBeforeObserver = function(obj, path, target, method) {
return this;
};
-Ember.notifyObservers = function(obj, keyName) {
+Ember.notifyBeforeObservers = function(obj, keyName) {
if (obj.isDestroying) { return; }
- notifyObservers(obj, changeEvent(keyName), keyName);
+ var eventName = beforeEvent(keyName), listeners, listenersDiff;
+ if (deferred) {
+ listeners = beforeObserverSet.add(obj, keyName, eventName);
+ listenersDiff = Ember.listenersDiff(obj, eventName, listeners);
+ Ember.sendEvent(obj, eventName, [obj, keyName], listenersDiff);
+ } else {
+ Ember.sendEvent(obj, eventName, [obj, keyName]);
+ }
};
-Ember.notifyBeforeObservers = function(obj, keyName) {
+Ember.notifyObservers = function(obj, keyName) {
if (obj.isDestroying) { return; }
- var guid, set, forceNotification = false;
-
+ var eventName = changeEvent(keyName), listeners;
if (deferred) {
- if (beforeObserverSet.add(obj, keyName)) {
- forceNotification = true;
- } else {
- return;
- }
+ listeners = observerSet.add(obj, keyName, eventName);
+ Ember.listenersUnion(obj, eventName, listeners);
+ } else {
+ Ember.sendEvent(obj, eventName, [obj, keyName]);
}
-
- notifyObservers(obj, beforeEvent(keyName), keyName, forceNotification);
};
-
})();
@@ -3206,33 +3255,46 @@ ComputedPropertyPrototype.get = function(obj, keyName) {
/* impl descriptor API */
ComputedPropertyPrototype.set = function(obj, keyName, value) {
var cacheable = this._cacheable,
+ func = this.func,
meta = metaFor(obj, cacheable),
watched = meta.watching[keyName],
oldSuspended = this._suspended,
hadCachedValue = false,
- ret;
+ cache = meta.cache,
+ cachedValue, ret;
+
this._suspended = obj;
- try {
- ret = this.func.call(obj, keyName, value);
- if (cacheable && keyName in meta.cache) {
- if (meta.cache[keyName] === ret) {
- return;
- }
+ try {
+ if (cacheable && cache.hasOwnProperty(keyName)) {
+ cachedValue = cache[keyName];
hadCachedValue = true;
}
+ // For backwards-compatibility with computed properties
+ // that check for arguments.length === 2 to determine if
+ // they are being get or set, only pass the old cached
+ // value if the computed property opts into a third
+ // argument.
+ if (func.length === 3) {
+ ret = func.call(obj, keyName, value, cachedValue);
+ } else {
+ ret = func.call(obj, keyName, value);
+ }
+
+ if (hadCachedValue && cachedValue === ret) { return; }
+
if (watched) { Ember.propertyWillChange(obj, keyName); }
- if (cacheable && hadCachedValue) {
- delete meta.cache[keyName];
+ if (hadCachedValue) {
+ delete cache[keyName];
}
if (cacheable) {
if (!watched && !hadCachedValue) {
addDependentKeys(this, obj, keyName, meta);
}
- meta.cache[keyName] = ret;
+ cache[keyName] = ret;
}
if (watched) { Ember.propertyDidChange(obj, keyName); }
@@ -3360,10 +3422,9 @@ Ember.computed.bool = function(dependentKey) {
*/
var o_create = Ember.create,
- meta = Ember.meta,
+ metaFor = Ember.meta,
metaPath = Ember.metaPath,
- guidFor = Ember.guidFor,
- a_slice = [].slice;
+ META_KEY = Ember.META_KEY;
/*
The event system uses a series of nested hashes to store listeners on an
@@ -3374,75 +3435,82 @@ var o_create = Ember.create,
// Object's meta hash
{
- listeners: { // variable name: `listenerSet`
- "foo:changed": { // variable name: `targetSet`
- [targetGuid]: { // variable name: `actionSet`
- [methodGuid]: { // variable name: `action`
- target: [Object object],
- method: [Function function]
- }
- }
- }
+ listeners: { // variable name: `listenerSet`
+ "foo:changed": [ // variable name: `actions`
+ [target, method, onceFlag]
+ ]
}
}
*/
-// Gets the set of all actions, keyed on the guid of each action's
-// method property.
-function actionSetFor(obj, eventName, target, writable) {
- return metaPath(obj, ['listeners', eventName, guidFor(target)], writable);
+function indexOf(array, target, method) {
+ var index = -1;
+ for (var i = 0, l = array.length; i < l; i++) {
+ if (target === array[i][0] && method === array[i][1]) { index = i; break; }
+ }
+ return index;
}
-// Gets the set of all targets, keyed on the guid of each action's
-// target property.
-function targetSetFor(obj, eventName) {
- var listenerSet = meta(obj, false).listeners;
- if (!listenerSet) { return false; }
+function actionsFor(obj, eventName) {
+ var meta = metaFor(obj, true),
+ actions;
+
+ if (!meta.listeners) { meta.listeners = {}; }
+
+ if (!meta.hasOwnProperty('listeners')) {
+ // setup inherited copy of the listeners object
+ meta.listeners = o_create(meta.listeners);
+ }
+
+ actions = meta.listeners[eventName];
+
+ // if there are actions, but the eventName doesn't exist in our listeners, then copy them from the prototype
+ if (actions && !meta.listeners.hasOwnProperty(eventName)) {
+ actions = meta.listeners[eventName] = meta.listeners[eventName].slice();
+ } else if (!actions) {
+ actions = meta.listeners[eventName] = [];
+ }
- return listenerSet[eventName] || false;
+ return actions;
}
-// TODO: This knowledge should really be a part of the
-// meta system.
-var SKIP_PROPERTIES = { __ember_source__: true };
+function actionsUnion(obj, eventName, otherActions) {
+ var meta = obj[META_KEY],
+ actions = meta && meta.listeners && meta.listeners[eventName];
-function iterateSet(obj, eventName, callback, params) {
- var targetSet = targetSetFor(obj, eventName);
- if (!targetSet) { return false; }
- // Iterate through all elements of the target set
- for(var targetGuid in targetSet) {
- if (SKIP_PROPERTIES[targetGuid]) { continue; }
-
- var actionSet = targetSet[targetGuid];
- if (actionSet) {
- // Iterate through the elements of the action set
- for(var methodGuid in actionSet) {
- if (SKIP_PROPERTIES[methodGuid]) { continue; }
-
- var action = actionSet[methodGuid];
- if (action) {
- if (callback(action, params, obj) === true) {
- return true;
- }
- }
- }
+ if (!actions) { return; }
+ for (var i = actions.length - 1; i >= 0; i--) {
+ var target = actions[i][0],
+ method = actions[i][1],
+ once = actions[i][2],
+ actionIndex = indexOf(otherActions, target, method);
+
+ if (actionIndex === -1) {
+ otherActions.push([target, method, once]);
}
}
- return false;
}
-function invokeAction(action, params, sender) {
- var method = action.method, target = action.target;
- // If there is no target, the target is the object
- // on which the event was fired.
- if (!target) { target = sender; }
- if ('string' === typeof method) { method = target[method]; }
- if (params) {
- method.apply(target, params);
- } else {
- method.apply(target);
+function actionsDiff(obj, eventName, otherActions) {
+ var meta = obj[META_KEY],
+ actions = meta && meta.listeners && meta.listeners[eventName],
+ diffActions = [];
+
+ if (!actions) { return; }
+ for (var i = actions.length - 1; i >= 0; i--) {
+ var target = actions[i][0],
+ method = actions[i][1],
+ once = actions[i][2],
+ actionIndex = indexOf(otherActions, target, method);
+
+ if (actionIndex !== -1) { continue; }
+
+ otherActions.push([target, method, once]);
+ diffActions.push([target, method, once]);
}
+
+ return diffActions;
}
/**
@@ -3455,7 +3523,7 @@ function invokeAction(action, params, sender) {
@param {Object|Function} targetOrMethod A target object or a function
@param {Function|String} method A function or the name of a function to be called on `target`
*/
-function addListener(obj, eventName, target, method, guid) {
+function addListener(obj, eventName, target, method, once) {
Ember.assert("You must pass at least an object and event name to Ember.addListener", !!obj && !!eventName);
if (!method && 'function' === typeof target) {
@@ -3463,14 +3531,12 @@ function addListener(obj, eventName, target, method, guid) {
target = null;
}
- var actionSet = actionSetFor(obj, eventName, target, true),
- // guid is used in case we wrapp given method to register
- // listener with method guid instead of the wrapper guid
- methodGuid = guid || guidFor(method);
+ var actions = actionsFor(obj, eventName),
+ actionIndex = indexOf(actions, target, method);
- if (!actionSet[methodGuid]) {
- actionSet[methodGuid] = { target: target, method: method };
- }
+ if (actionIndex !== -1) { return; }
+
+ actions.push([target, method, once]);
if ('function' === typeof obj.didAddListener) {
obj.didAddListener(eventName, target, method);
@@ -3497,13 +3563,14 @@ function removeListener(obj, eventName, target, method) {
target = null;
}
- function _removeListener(target, method) {
- var actionSet = actionSetFor(obj, eventName, target, true),
- methodGuid = guidFor(method);
+ function _removeListener(target, method, once) {
+ var actions = actionsFor(obj, eventName),
+ actionIndex = indexOf(actions, target, method);
+
+ // action doesn't exist, give up silently
+ if (actionIndex === -1) { return; }
- // we can't simply delete this parameter, because if we do, we might
- // re-expose the property from the prototype chain.
- if (actionSet && actionSet[methodGuid]) { actionSet[methodGuid] = null; }
+ actions.splice(actionIndex, 1);
if ('function' === typeof obj.didRemoveListener) {
obj.didRemoveListener(eventName, target, method);
@@ -3513,9 +3580,13 @@ function removeListener(obj, eventName, target, method) {
if (method) {
_removeListener(target, method);
} else {
- iterateSet(obj, eventName, function(action) {
- _removeListener(action.target, action.method);
- });
+ var meta = obj[META_KEY],
+ actions = meta && meta.listeners && meta.listeners[eventName];
+
+ if (!actions) { return; }
+ for (var i = actions.length - 1; i >= 0; i--) {
+ _removeListener(actions[i][0], actions[i][1]);
+ }
}
}
@@ -3543,15 +3614,18 @@ function suspendListener(obj, eventName, target, method, callback) {
target = null;
}
- var actionSet = actionSetFor(obj, eventName, target, true),
- methodGuid = guidFor(method),
- action = actionSet && actionSet[methodGuid];
+ var actions = actionsFor(obj, eventName),
+ actionIndex = indexOf(actions, target, method),
+ action;
+
+ if (actionIndex !== -1) {
+ action = actions.splice(actionIndex, 1)[0];
+ }
- actionSet[methodGuid] = null;
try {
return callback.call(target);
} finally {
- actionSet[methodGuid] = action;
+ if (action) { actions.push(action); }
}
}
@@ -3579,31 +3653,32 @@ function suspendListeners(obj, eventNames, target, method, callback) {
target = null;
}
- var oldActions = [],
- actionSets = [],
- eventName, actionSet, methodGuid, action, i, l;
+ var removedActions = [],
+ eventName, actions, action, i, l;
for (i=0, l=eventNames.length; i<l; i++) {
eventName = eventNames[i];
- actionSet = actionSetFor(obj, eventName, target, true),
- methodGuid = guidFor(method);
-
- oldActions.push(actionSet && actionSet[methodGuid]);
- actionSets.push(actionSet);
+ actions = actionsFor(obj, eventName);
+ var actionIndex = indexOf(actions, target, method);
- actionSet[methodGuid] = null;
+ if (actionIndex !== -1) {
+ removedActions.push(actions.splice(actionIndex, 1)[0]);
+ }
}
try {
return callback.call(target);
} finally {
- for (i=0, l=oldActions.length; i<l; i++) {
- eventName = eventNames[i];
- actionSets[i][methodGuid] = oldActions[i];
+ for (i = 0, l = removedActions.length; i < l; i++) {
+ actions.push(removedActions[i]);
}
}
}
+// TODO: This knowledge should really be a part of the
+// meta system.
+var SKIP_PROPERTIES = { __ember_source__: true };
+
/**
@private
@@ -3614,7 +3689,7 @@ function suspendListeners(obj, eventNames, target, method, callback) {
@param obj
*/
function watchedEvents(obj) {
- var listeners = meta(obj, false).listeners, ret = [];
+ var listeners = obj[META_KEY].listeners, ret = [];
if (listeners) {
for(var eventName in listeners) {
@@ -3634,41 +3709,36 @@ function watchedEvents(obj) {
@param {Array} params
@return true
*/
-function sendEvent(obj, eventName, params) {
+function sendEvent(obj, eventName, params, actions) {
// first give object a chance to handle it
if (obj !== Ember && 'function' === typeof obj.sendEvent) {
obj.sendEvent(eventName, params);
}
- iterateSet(obj, eventName, invokeAction, params);
- return true;
-}
+ if (!actions) {
+ var meta = obj[META_KEY];
+ actions = meta && meta.listeners && meta.listeners[eventName];
+ }
-/**
- @private
- @method deferEvent
- @for Ember
- @param obj
- @param {String} eventName
- @param {Array} params
-*/
-function deferEvent(obj, eventName, params) {
- var actions = [];
- iterateSet(obj, eventName, function (action) {
- actions.push(action);
- });
+ if (!actions) { return; }
- return function() {
- if (obj.isDestroyed) { return; }
+ for (var i = actions.length - 1; i >= 0; i--) { // looping in reverse for once listeners
+ if (!actions[i]) { continue; }
- if (obj !== Ember && 'function' === typeof obj.sendEvent) {
- obj.sendEvent(eventName, params);
- }
+ var target = actions[i][0],
+ method = actions[i][1],
+ once = actions[i][2];
- for (var i=0, len=actions.length; i < len; ++i) {
- invokeAction(actions[i], params, obj);
+ if (once) { removeListener(obj, eventName, target, method); }
+ if (!target) { target = obj; }
+ if ('string' === typeof method) { method = target[method]; }
+ if (params) {
+ method.apply(target, params);
+ } else {
+ method.apply(target);
}
- };
+ }
+ return true;
}
/**
@@ -3679,15 +3749,10 @@ function deferEvent(obj, eventName, params) {
@param {String} eventName
*/
function hasListeners(obj, eventName) {
- if (iterateSet(obj, eventName, function() { return true; })) {
- return true;
- }
+ var meta = obj[META_KEY],
+ actions = meta && meta.listeners && meta.listeners[eventName];
- // no listeners! might as well clean this up so it is faster later.
- var set = metaPath(obj, ['listeners'], true);
- set[eventName] = null;
-
- return false;
+ return !!(actions && actions.length);
}
/**
@@ -3699,9 +3764,17 @@ function hasListeners(obj, eventName) {
*/
function listenersFor(obj, eventName) {
var ret = [];
- iterateSet(obj, eventName, function (action) {
- ret.push([action.target, action.method]);
- });
+ var meta = obj[META_KEY],
+ actions = meta && meta.listeners && meta.listeners[eventName];
+
+ if (!actions) { return ret; }
+
+ for (var i = 0, l = actions.length; i < l; i++) {
+ var target = actions[i][0],
+ method = actions[i][1];
+ ret.push([target, method]);
+ }
+
return ret;
}
@@ -3713,7 +3786,8 @@ Ember.sendEvent = sendEvent;
Ember.hasListeners = hasListeners;
Ember.watchedEvents = watchedEvents;
Ember.listenersFor = listenersFor;
-Ember.deferEvent = deferEvent;
+Ember.listenersDiff = actionsDiff;
+Ember.listenersUnion = actionsUnion;
})();
@@ -3919,7 +3993,7 @@ Ember.RunLoop = RunLoop;
call.
Ember.run(function(){
- // code to be execute within a RunLoop
+ // code to be execute within a RunLoop
});
@class run
@@ -3953,7 +4027,7 @@ var run = Ember.run;
an lower-level way to use a RunLoop instead of using Ember.run().
Ember.run.begin();
- // code to be execute within a RunLoop
+ // code to be execute within a RunLoop
Ember.run.end();
@method begin
@@ -3969,7 +4043,7 @@ Ember.run.begin = function() {
instead of using Ember.run().
Ember.run.begin();
- // code to be execute within a RunLoop
+ // code to be execute within a RunLoop
Ember.run.end();
@method end
@@ -4838,13 +4912,22 @@ function isMethod(obj) {
}
function mergeMixins(mixins, m, descs, values, base) {
- var len = mixins.length, idx, mixin, guid, props, value, key, ovalue, concats;
+ var len = mixins.length, idx, mixin, guid, props, value, key, ovalue, concats, meta;
function removeKeys(keyName) {
delete descs[keyName];
delete values[keyName];
}
+ function cloneDescriptor(desc) {
+ var newDesc = new Ember.ComputedProperty();