From 9b65962e8bfcf2386c9e479916cc46752d2847e1 Mon Sep 17 00:00:00 2001 From: IgorT Date: Sat, 7 Jun 2014 15:52:32 -0300 Subject: [PATCH] Coalesce find requests, add support for preloading data This PR brings following improvements/changes: 1. Find calls from the same runloop will be coalesced into findMany requests if the adapter has a findMany method 2. Adds a groupRecordsForFindMany hook on the adapter that allows you to decide how to coalesce the record requests. This will enable fixing of bugs such as #651, by segmenting on the number of records 3. Allows users to preset attributes and relationships on a model when doing a find call. For relationships you can either pass in a record or an id 4. Gives rudimentary support for nested urls, by passing in the record object to the buildUrl method 5. Removes the owner being passed to findMany becuase it can be looked up on the original record 6. Adds couple special cased SSOT features that were needed for buildUrl/removal of owner property to be viable This PR lays the groundwork for: 1. Adding support for reloading hasManys 2. Making nice sugar for nestedUrls in the RestAdater --- .../ember-data/lib/adapters/rest_adapter.js | 57 ++- packages/ember-data/lib/system/adapter.js | 21 +- packages/ember-data/lib/system/model/model.js | 47 +++ .../ember-data/lib/system/model/states.js | 6 + .../lib/system/record_arrays/many_array.js | 4 +- .../lib/system/relationships/belongs_to.js | 10 +- .../lib/system/relationships/has_many.js | 15 +- packages/ember-data/lib/system/store.js | 217 +++++++---- .../integration/adapter/rest_adapter_test.js | 108 ++++++ .../relationships/has_many_test.js | 8 +- .../tests/unit/model/relationships_test.js | 40 +- .../tests/unit/store/adapter_interop_test.js | 350 +++++++++++++++++- .../tests/unit/store/unload_test.js | 5 +- tests/ember_configuration.js | 4 +- 14 files changed, 754 insertions(+), 138 deletions(-) diff --git a/packages/ember-data/lib/adapters/rest_adapter.js b/packages/ember-data/lib/adapters/rest_adapter.js index 12dd13441b2..29a86dc7e54 100644 --- a/packages/ember-data/lib/adapters/rest_adapter.js +++ b/packages/ember-data/lib/adapters/rest_adapter.js @@ -205,10 +205,11 @@ export default Adapter.extend({ @param {DS.Store} store @param {subclass of DS.Model} type @param {String} id + @param {DS.Model} record @return {Promise} promise */ - find: function(store, type, id) { - return this.ajax(this.buildURL(type.typeKey, id), 'GET'); + find: function(store, type, id, record) { + return this.ajax(this.buildURL(type.typeKey, id, record), 'GET'); }, /** @@ -288,10 +289,11 @@ export default Adapter.extend({ @param {DS.Store} store @param {subclass of DS.Model} type @param {Array} ids + @param {Array} records @return {Promise} promise */ - findMany: function(store, type, ids) { - return this.ajax(this.buildURL(type.typeKey), 'GET', { data: { ids: ids } }); + findMany: function(store, type, ids, records) { + return this.ajax(this.buildURL(type.typeKey, ids, records), 'GET', { data: { ids: ids } }); }, /** @@ -391,7 +393,7 @@ export default Adapter.extend({ serializer.serializeIntoHash(data, type, record, { includeId: true }); - return this.ajax(this.buildURL(type.typeKey), "POST", { data: data }); + return this.ajax(this.buildURL(type.typeKey, null, record), "POST", { data: data }); }, /** @@ -418,7 +420,7 @@ export default Adapter.extend({ var id = get(record, 'id'); - return this.ajax(this.buildURL(type.typeKey, id), "PUT", { data: data }); + return this.ajax(this.buildURL(type.typeKey, id, record), "PUT", { data: data }); }, /** @@ -435,7 +437,7 @@ export default Adapter.extend({ deleteRecord: function(store, type, record) { var id = get(record, 'id'); - return this.ajax(this.buildURL(type.typeKey, id), "DELETE"); + return this.ajax(this.buildURL(type.typeKey, id, record), "DELETE"); }, /** @@ -451,15 +453,20 @@ export default Adapter.extend({ @method buildURL @param {String} type @param {String} id + @param {DS.Model} record @return {String} url */ - buildURL: function(type, id) { - var url = []; - var host = get(this, 'host'); - var prefix = this.urlPrefix(); + buildURL: function(type, id, record) { + var url = [], + host = get(this, 'host'), + prefix = this.urlPrefix(); if (type) { url.push(this.pathForType(type)); } - if (id) { url.push(id); } + + //We might get passed in an array of ids from findMany + //in which case we want to not modify the url, as the + //ids will be passed in through a query param + if (id && !Ember.isArray(id)) { url.push(id); } if (prefix) { url.unshift(prefix); } @@ -504,6 +511,32 @@ export default Adapter.extend({ return url.join('/'); }, + _stripIDFromURL: function(store, record) { + var type = store.modelFor(record); + var url = this.buildURL(type.typeKey, record.get('id'), record); + + var expandedURL = url.split('/'); + if (expandedURL[expandedURL.length -1 ] === record.get('id')){ + expandedURL[expandedURL.length - 1] = ""; + } + return expandedURL.join('/'); + }, + + groupRecordsForFindMany: function (store, records) { + var groups = Ember.MapWithDefault.create({defaultValue: function(){return [];}}); + var adapter = this; + forEach.call(records, function(record){ + var baseUrl = adapter._stripIDFromURL(store, record); + groups.get(baseUrl).push(record); + }); + var groupsArray = []; + groups.forEach(function(key, group){ + groupsArray.push(group); + }); + + return groupsArray; + }, + /** Determines the pathname for a given type. diff --git a/packages/ember-data/lib/system/adapter.js b/packages/ember-data/lib/system/adapter.js index 8e2700f6c17..81fd008689f 100644 --- a/packages/ember-data/lib/system/adapter.js +++ b/packages/ember-data/lib/system/adapter.js @@ -445,14 +445,33 @@ var Adapter = Ember.Object.extend({ @param {DS.Store} store @param {subclass of DS.Model} type the DS.Model class of the records @param {Array} ids + @param {Array} records @return {Promise} promise */ - findMany: function(store, type, ids) { + findMany: function(store, type, ids, records) { var promises = map.call(ids, function(id) { return this.find(store, type, id); }, this); return Ember.RSVP.all(promises); + }, + + /** + Organize records into groups, each of which is to be passed to separate + calls to `findMany`. + + For example, if your api has nested URLs that depend on the parent, you will + want to group records by their parent. + + The default implementation returns the records as a single group. + + @method groupRecordsForFindMany + @param {Array} records + @returns {Array} an array of arrays of records, each of which is to be + loaded separately by `findMany`. + */ + groupRecordsForFindMany: function (store, records) { + return [records]; } }); diff --git a/packages/ember-data/lib/system/model/model.js b/packages/ember-data/lib/system/model/model.js index cfc2fccd872..08d54dedc44 100644 --- a/packages/ember-data/lib/system/model/model.js +++ b/packages/ember-data/lib/system/model/model.js @@ -9,6 +9,7 @@ var get = Ember.get; var set = Ember.set; var merge = Ember.merge; var Promise = Ember.RSVP.Promise; +var forEach = Ember.EnumerableUtils.forEach; var JSONSerializer; var retrieveFromCurrentState = Ember.computed('currentState', function(key, value) { @@ -629,6 +630,52 @@ var Model = Ember.Object.extend(Ember.Evented, { get(this, 'store').dataWasUpdated(this.constructor, this); }, + _preloadData: function(preload) { + var record = this; + //TODO(Igor) consider the polymorphic case + forEach(Ember.keys(preload), function(key) { + var preloadValue = get(preload, key); + var relationshipMeta = record.constructor.metaForProperty(key); + if (relationshipMeta.isRelationship) { + record._preloadRelationship(key, preloadValue); + } else { + get(record, '_data')[key] = preloadValue; + } + }); + }, + + _preloadRelationship: function(key, preloadValue) { + var relationshipMeta = this.constructor.metaForProperty(key); + var type = relationshipMeta.type; + if (relationshipMeta.kind === 'hasMany'){ + this._preloadHasMany(key, preloadValue, type); + } else { + this._preloadBelongsTo(key, preloadValue, type); + } + }, + + _preloadHasMany: function(key, preloadValue, type) { + Ember.assert("You need to pass in an array to set a hasMany property on a record", Ember.isArray(preloadValue)); + var record = this; + + forEach(preloadValue, function(recordToPush) { + recordToPush = record._convertStringOrNumberIntoRecord(recordToPush, type); + get(record, key).pushObject(recordToPush); + }); + }, + + _preloadBelongsTo: function(key, preloadValue, type){ + var recordToPush = this._convertStringOrNumberIntoRecord(preloadValue, type); + set(this, key, recordToPush); + }, + + _convertStringOrNumberIntoRecord: function(value, type) { + if (Ember.typeOf(value) === 'string' || Ember.typeOf(value) === 'number'){ + return this.store.recordForId(type, value); + } + return value; + }, + /** Returns an object, whose keys are changed properties, and value is an [oldProp, newProp] array. diff --git a/packages/ember-data/lib/system/model/states.js b/packages/ember-data/lib/system/model/states.js index 941eddd5429..df46d71cbc0 100644 --- a/packages/ember-data/lib/system/model/states.js +++ b/packages/ember-data/lib/system/model/states.js @@ -254,6 +254,9 @@ var DirtyState = { // EVENTS didSetProperty: didSetProperty, + //TODO(Igor) think this through + loadingData: Ember.K, + propertyWasReset: function(record, name) { var stillDirty = false; @@ -536,6 +539,9 @@ var RootState = { // FLAGS isLoaded: true, + //TODO(Igor) think this through + loadingData: Ember.K, + // SUBSTATES // If there are no local changes to a record, it remains diff --git a/packages/ember-data/lib/system/record_arrays/many_array.js b/packages/ember-data/lib/system/record_arrays/many_array.js index e34df9f34c4..b32e390189d 100644 --- a/packages/ember-data/lib/system/record_arrays/many_array.js +++ b/packages/ember-data/lib/system/record_arrays/many_array.js @@ -121,8 +121,8 @@ export default RecordArray.extend({ var store = get(this, 'store'); var owner = get(this, 'owner'); - var unloadedRecords = records.filterBy('isEmpty', true); - store.fetchMany(unloadedRecords, owner); + var unloadedRecords = records.filterProperty('isEmpty', true); + store.scheduleFetchMany(unloadedRecords, owner); }, // Overrides Ember.Array's replace method to implement diff --git a/packages/ember-data/lib/system/relationships/belongs_to.js b/packages/ember-data/lib/system/relationships/belongs_to.js index 2ba087d4043..d75c6a2033f 100644 --- a/packages/ember-data/lib/system/relationships/belongs_to.js +++ b/packages/ember-data/lib/system/relationships/belongs_to.js @@ -35,7 +35,13 @@ function asyncBelongsTo(type, options, meta) { var belongsTo = data[key]; if (!isNone(belongsTo)) { - promise = store.fetchRecord(belongsTo) || Promise.cast(belongsTo, promiseLabel); + var inverse = this.constructor.inverseFor(key); + //but for now only in the oneToOne case + if (inverse && inverse.kind === 'belongsTo'){ + set(belongsTo, inverse.name, this); + } + //TODO(Igor) after OR doesn't seem that will be called + promise = store.findById(belongsTo.constructor, belongsTo.get('id')) || Promise.cast(belongsTo, promiseLabel); return PromiseObject.create({ promise: promise }); @@ -139,7 +145,7 @@ function belongsTo(type, options) { if (isNone(belongsTo)) { return null; } - store.fetchRecord(belongsTo); + store.findById(belongsTo.constructor, belongsTo.get('id')); return belongsTo; }).meta(meta); diff --git a/packages/ember-data/lib/system/relationships/has_many.js b/packages/ember-data/lib/system/relationships/has_many.js index 17211e743db..ce9ea7fce52 100644 --- a/packages/ember-data/lib/system/relationships/has_many.js +++ b/packages/ember-data/lib/system/relationships/has_many.js @@ -12,6 +12,7 @@ import { var get = Ember.get; var set = Ember.set; var setProperties = Ember.setProperties; +var map = Ember.EnumerableUtils.map; function asyncHasMany(type, options, meta) { return Ember.computed('data', function(key) { @@ -28,7 +29,19 @@ function asyncHasMany(type, options, meta) { if (link) { rel = store.findHasMany(this, link, relationshipFromMeta(store, meta), resolver); } else { - rel = store.findMany(this, data[key], typeForRelationshipMeta(store, meta), resolver); + //This is a temporary workaround for setting owner on the relationship + //until single source of truth lands. It only works for OneToMany atm + var records = data[key]; + var inverse = this.constructor.inverseFor(key); + var owner = this; + if (inverse && records) { + if (inverse.kind === 'belongsTo'){ + map(records, function(record){ + set(record, inverse.name, owner); + }); + } + } + rel = store.findMany(owner, data[key], typeForRelationshipMeta(store, meta), resolver); } // cache the promise so we can use it // when we come back and don't need to rebuild diff --git a/packages/ember-data/lib/system/store.js b/packages/ember-data/lib/system/store.js index fc7d9303068..5308b85c846 100644 --- a/packages/ember-data/lib/system/store.js +++ b/packages/ember-data/lib/system/store.js @@ -141,6 +141,8 @@ Store = Ember.Object.extend({ }); this._relationshipChanges = {}; this._pendingSave = []; + //Used to keep track of all the find requests that need to be coalesced + this._pendingFetch = Ember.Map.create(); }, /** @@ -377,7 +379,7 @@ Store = Ember.Object.extend({ @param {Object|String|Integer|null} id @return {Promise} promise */ - find: function(type, id) { + find: function(type, id, preload) { Ember.assert("You need to pass a type to the store's find method", arguments.length >= 1); Ember.assert("You may not pass `" + id + "` as id to the store's find method", arguments.length === 1 || !Ember.isNone(id)); @@ -390,7 +392,7 @@ Store = Ember.Object.extend({ return this.findQuery(type, id); } - return this.findById(type, coerceId(id)); + return this.findById(type, coerceId(id), preload); }, /** @@ -402,10 +404,22 @@ Store = Ember.Object.extend({ @param {String|Integer} id @return {Promise} promise */ - findById: function(typeName, id) { + findById: function(typeName, id, preload) { + var fetchedRecord; + var type = this.modelFor(typeName); var record = this.recordForId(type, id); - var fetchedRecord = this.fetchRecord(record); + + if (preload) { + record._preloadData(preload); + } + + if (get(record, 'isEmpty')) { + fetchedRecord = this.scheduleFetch(record); + //TODO double check about reloading + } else if (get(record, 'isLoading')){ + fetchedRecord = record._loadingPromise; + } return promiseObject(fetchedRecord || record, "DS: Store#findById " + type + " with id: " + id); }, @@ -440,22 +454,125 @@ Store = Ember.Object.extend({ @return {Promise} promise */ fetchRecord: function(record) { - if (isNone(record)) { return null; } - if (record._loadingPromise) { return record._loadingPromise; } - if (!get(record, 'isEmpty')) { return null; } + var type = record.constructor, + id = get(record, 'id'); - var type = record.constructor; - var id = get(record, 'id'); var adapter = this.adapterFor(type); Ember.assert("You tried to find a record but you have no adapter (for " + type + ")", adapter); Ember.assert("You tried to find a record but your adapter (for " + type + ") does not implement 'find'", adapter.find); - var promise = _find(adapter, this, type, id); + var promise = _find(adapter, this, type, id, record); + return promise; + }, + + scheduleFetchMany: function(records) { + return Ember.RSVP.all(map(records, this.scheduleFetch, this)); + }, + + scheduleFetch: function(record) { + var type = record.constructor; + if (isNone(record)) { return null; } + if (record._loadingPromise) { return record._loadingPromise; } + + var resolver = Ember.RSVP.defer("Fetching " + type + "with id: " + record.get('id')); + var recordResolverPair = {record: record, resolver: resolver}; + var promise = resolver.promise; + record.loadingData(promise); + + if (!this._pendingFetch.get(type)){ + this._pendingFetch.set(type, [recordResolverPair]); + } else { + this._pendingFetch.get(type).push(recordResolverPair); + } + Ember.run.scheduleOnce('afterRender', this, this.flushAllPendingFetches); + return promise; }, + flushAllPendingFetches: function(){ + if (this.isDestroyed || this.isDestroying) { + return; + } + + this._pendingFetch.forEach(this._flushPendingFetchForType, this); + this._pendingFetch = Ember.Map.create(); + }, + + _flushPendingFetchForType: function (type, recordResolverPairs) { + var store = this; + var adapter = store.adapterFor(type); + var shouldCoalesce = !!adapter.findMany; + var records = Ember.A(recordResolverPairs).mapBy('record'); + var resolvers = Ember.A(recordResolverPairs).mapBy('resolver'); + + function _fetchRecord(recordResolverPair) { + var resolver = recordResolverPair.resolver; + store.fetchRecord(recordResolverPair.record).then(function(record){ + resolver.resolve(record); + }, function(error){ + resolver.reject(error); + }); + } + + function resolveFoundRecords(records) { + forEach(records, function(record){ + var pair = Ember.A(recordResolverPairs).findBy('record', record); + if (pair){ + var resolver = pair.resolver; + resolver.resolve(record); + } + }); + } + + function makeMissingRecordsRejector(requestedRecords) { + return function rejectMissingRecords(resolvedRecords) { + var missingRecords = requestedRecords.without(resolvedRecords); + rejectRecords(missingRecords); + }; + } + + function makeRecordsRejector(records) { + return function (error) { + rejectRecords(records, error); + }; + } + + function rejectRecords(records, error) { + forEach(records, function(record){ + var pair = Ember.A(recordResolverPairs).findBy('record', record); + if (pair){ + var resolver = pair.resolver; + resolver.reject(error); + } + }); + } + + if (recordResolverPairs.length === 1) { + _fetchRecord(recordResolverPairs[0]); + } else if (shouldCoalesce) { + var groups = adapter.groupRecordsForFindMany(this, records); + forEach(groups, function (groupOfRecords) { + var requestedRecords = Ember.A(groupOfRecords); + var ids = requestedRecords.mapBy('id'); + if (ids.length > 1) { + _findMany(adapter, store, type, ids, requestedRecords). + then(resolveFoundRecords). + then(makeMissingRecordsRejector(requestedRecords)). + then(null, makeRecordsRejector(requestedRecords)); + } else if (ids.length === 1) { + var pair = Ember.A(recordResolverPairs).findBy('record', groupOfRecords[0]); + _fetchRecord(pair); + } else { + Ember.assert("You cannot return an empty array from adapter's method groupRecordsForFindMany", false); + } + }); + } else { + forEach(recordResolverPairs, _fetchRecord); + } + }, + /** Get a record by a given type and ID without triggering a fetch. @@ -505,54 +622,7 @@ Store = Ember.Object.extend({ Ember.assert("You tried to reload a record but you have no adapter (for " + type + ")", adapter); Ember.assert("You tried to reload a record but your adapter does not implement `find`", adapter.find); - return _find(adapter, this, type, id); - }, - - /** - This method takes a list of records, groups the records by type, - converts the records into IDs, and then invokes the adapter's `findMany` - method. - - The records are grouped by type to invoke `findMany` on adapters - for each unique type in records. - - It is used both by a brand new relationship (via the `findMany` - method) or when the data underlying an existing relationship - changes. - - @method fetchMany - @private - @param {Array} records - @param {DS.Model} owner - @return {Promise} promise - */ - fetchMany: function(records, owner) { - if (!records.length) { - return Ember.RSVP.resolve(records); - } - - // Group By Type - var recordsByTypeMap = Ember.MapWithDefault.create({ - defaultValue: function() { return Ember.A(); } - }); - - forEach(records, function(record) { - recordsByTypeMap.get(record.constructor).push(record); - }); - - var promises = []; - - forEach(recordsByTypeMap, function(type, records) { - var ids = records.mapBy('id'), - adapter = this.adapterFor(type); - - Ember.assert("You tried to load many records but you have no adapter (for " + type + ")", adapter); - Ember.assert("You tried to load many records but your adapter does not implement `findMany`", adapter.findMany); - - promises.push(_findMany(adapter, this, type, ids, owner)); - }, this); - - return Ember.RSVP.all(promises); + return this.scheduleFetch(record); }, /** @@ -604,13 +674,9 @@ Store = Ember.Object.extend({ findMany: function(owner, inputRecords, typeName, resolver) { var type = this.modelFor(typeName); var records = Ember.A(inputRecords); - var unloadedRecords = records.filterBy('isEmpty', true); - var manyArray = this.recordArrayManager.createManyArray(type, records); - - forEach(unloadedRecords, function(record) { - record.loadingData(); - }); + var unloadedRecords = records.filterProperty('isEmpty', true); + var manyArray = this.recordArrayManager.createManyArray(type, records); manyArray.loadingRecordsCount = unloadedRecords.length; if (unloadedRecords.length) { @@ -618,7 +684,7 @@ Store = Ember.Object.extend({ this.recordArrayManager.registerWaitingRecordArray(record, manyArray); }, this); - resolver.resolve(this.fetchMany(unloadedRecords, owner)); + resolver.resolve(this.scheduleFetchMany(unloadedRecords, owner)); } else { if (resolver) { resolver.resolve(); } manyArray.set('isLoaded', true); @@ -1776,10 +1842,10 @@ function _bind(fn) { }; } -function _find(adapter, store, type, id) { - var promise = adapter.find(store, type, id); - var serializer = serializerForAdapter(adapter, type); - var label = "DS: Handle Adapter#find of " + type + " with id: " + id; +function _find(adapter, store, type, id, record) { + var promise = adapter.find(store, type, id, record), + serializer = serializerForAdapter(adapter, type), + label = "DS: Handle Adapter#find of " + type + " with id: " + id; promise = Promise.cast(promise, label); promise = _guard(promise, _bind(_objectIsAlive, store)); @@ -1798,12 +1864,12 @@ function _find(adapter, store, type, id) { }, "DS: Extract payload of '" + type + "'"); } -function _findMany(adapter, store, type, ids, owner) { - var promise = adapter.findMany(store, type, ids, owner); - var serializer = serializerForAdapter(adapter, type); - var label = "DS: Handle Adapter#findMany of " + type; - var guardedPromise; +function _findMany(adapter, store, type, ids, records) { + var promise = adapter.findMany(store, type, ids, records), + serializer = serializerForAdapter(adapter, type), + label = "DS: Handle Adapter#findMany of " + type; + var guardedPromise; promise = Promise.cast(promise, label); promise = _guard(promise, _bind(_objectIsAlive, store)); @@ -1812,7 +1878,7 @@ function _findMany(adapter, store, type, ids, owner) { Ember.assert("The response from a findMany must be an Array, not " + Ember.inspect(payload), Ember.typeOf(payload) === 'array'); - store.pushMany(type, payload); + return store.pushMany(type, payload); }, null, "DS: Extract payload of " + type); } @@ -1929,4 +1995,5 @@ export { PromiseArray, PromiseObject }; + export default Store; diff --git a/packages/ember-data/tests/integration/adapter/rest_adapter_test.js b/packages/ember-data/tests/integration/adapter/rest_adapter_test.js index d8d988bff9e..da23c5207d5 100644 --- a/packages/ember-data/tests/integration/adapter/rest_adapter_test.js +++ b/packages/ember-data/tests/integration/adapter/rest_adapter_test.js @@ -1172,6 +1172,114 @@ test('buildURL - with camelized names', function() { })); }); +test('buildURL - buildURL takes a record from find', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + adapter.buildURL = function(type, id, record) { + return "/posts/" + record.get('post.id') + '/comments/' + record.get('id'); + }; + + ajaxResponse({ comments: [{ id: 1 }] }); + + var post = store.push('post', { id: 2 }); + store.find('comment', 1, {post: post}).then(async(function(post) { + equal(passedUrl, "/posts/2/comments/1"); + })); +}); + +test('buildURL - buildURL takes the records from findMany', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + Post.reopen({ comments: DS.hasMany('comment', {async: true}) }); + + adapter.buildURL = function(type, ids, records) { + return "/posts/" + records.get('firstObject.post.id') + '/comments/'; + }; + + ajaxResponse({ comments: [{ id: 1 }, {id:2}, {id:3}] }); + + var post = store.push('post', { id: 2, comments: [1,2,3] }); + + post.get('comments').then(async(function(post) { + equal(passedUrl, "/posts/2/comments/"); + })); +}); + +test('buildURL - buildURL takes a record from create', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + adapter.buildURL = function(type, id, record) { + return "/posts/" + record.get('post.id') + '/comments/'; + }; + + ajaxResponse({ comments: [{ id: 1 }] }); + + var post = store.push('post', { id: 2 }); + var comment = store.createRecord('comment'); + comment.set('post', post); + comment.save().then(async(function(post) { + equal(passedUrl, "/posts/2/comments/"); + })); +}); + +test('buildURL - buildURL takes a record from update', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + adapter.buildURL = function(type, id, record) { + return "/posts/" + record.get('post.id') + '/comments/' + record.get('id'); + }; + + ajaxResponse({ comments: [{ id: 1 }] }); + + var post = store.push('post', { id: 2 }); + var comment = store.push('comment', { id: 1 }); + comment.set('post', post); + comment.save().then(async(function(post) { + equal(passedUrl, "/posts/2/comments/1"); + })); +}); + +test('buildURL - buildURL takes a record from delete', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + adapter.buildURL = function(type, id, record) { + return '/comments/' + record.get('id'); + }; + + ajaxResponse({ comments: [{ id: 1 }] }); + + var post = store.push('post', { id: 2 }); + var comment = store.push('comment', { id: 1 }); + + comment.set('post', post); + comment.deleteRecord(); + comment.save().then(async(function(post) { + equal(passedUrl, "/comments/1"); + })); +}); + +test('groupRecordsForFindMany groups records based on their url', function() { + Comment.reopen({ post: DS.belongsTo('post') }); + Post.reopen({ comments: DS.hasMany('comment', {async: true}) }); + + adapter.buildURL = function(type, id, record) { + if (id === '1'){ + return '/comments/1'; + } else { + return '/other_comments/' + id; + } + }; + + adapter.find = function(store, type, id, record ) { + equal(id, '1'); + return Ember.RSVP.resolve({comments: {id:1}}); + }; + + adapter.findMany = function(store, type, ids, records ) { + deepEqual(ids, ['2', '3']); + return Ember.RSVP.resolve({comments: [{id:2}, {id:3}]}); + }; + + var post = store.push('post', { id: 2, comments: [1,2,3] }); + + post.get('comments'); +}); + test('normalizeKey - to set up _ids and _id', function() { env.container.register('serializer:application', DS.RESTSerializer.extend({ keyForAttribute: function(attr) { diff --git a/packages/ember-data/tests/integration/relationships/has_many_test.js b/packages/ember-data/tests/integration/relationships/has_many_test.js index 566e80b0239..1a4c4399bb0 100644 --- a/packages/ember-data/tests/integration/relationships/has_many_test.js +++ b/packages/ember-data/tests/integration/relationships/has_many_test.js @@ -163,16 +163,16 @@ test("When a polymorphic hasMany relationship is accessed, the adapter's findMan })); }); -test("When a polymorphic hasMany relationship is accessed, the store can call multiple adapters' findMany method if the records are not loaded", function() { +test("When a polymorphic hasMany relationship is accessed, the store can call multiple adapters' findMany or find methods if the records are not loaded", function() { User.reopen({ messages: hasMany('message', { polymorphic: true, async: true }) }); - env.adapter.findMany = function(store, type) { + env.adapter.find = function(store, type) { if (type === Post) { - return Ember.RSVP.resolve([{ id: 1 }]); + return Ember.RSVP.resolve({ id: 1 }); } else if (type === Comment) { - return Ember.RSVP.resolve([{ id: 3 }]); + return Ember.RSVP.resolve({ id: 3 }); } }; diff --git a/packages/ember-data/tests/unit/model/relationships_test.js b/packages/ember-data/tests/unit/model/relationships_test.js index 36d407bf8ea..eff9bf9a467 100644 --- a/packages/ember-data/tests/unit/model/relationships_test.js +++ b/packages/ember-data/tests/unit/model/relationships_test.js @@ -562,7 +562,7 @@ test("calling createRecord and passing in an undefined value for a relationship })); }); -test("findMany is passed the owner record for adapters when some of the object graph is already loaded", function() { +test("When finding a hasMany relationship the inverse belongsTo relationship is available immediately", function() { var Occupation = DS.Model.extend({ description: DS.attr('string'), person: DS.belongsTo('person') @@ -580,11 +580,8 @@ test("findMany is passed the owner record for adapters when some of the object g var env = setupStore({ occupation: Occupation, person: Person }), store = env.store; - env.adapter.findMany = function(store, type, ids, owner) { - equal(type, Occupation, "type should be Occupation"); - deepEqual(ids, ['5', '2'], "ids should be 5 and 2"); - equal(get(owner, 'id'), 1, "the owner record id should be 1"); - + env.adapter.findMany = function(store, type, ids, records) { + equal(records[0].get('person.id'), '1'); return Ember.RSVP.resolve([{ id: 5, description: "fifth" }, { id: 2, description: "second" }]); }; @@ -603,7 +600,8 @@ test("findMany is passed the owner record for adapters when some of the object g })); }); -test("findMany is passed the owner record for adapters when none of the object graph is loaded", function() { +test("When finding a belongsTo relationship the inverse belongsTo relationship is available immediately", function() { + expect(1); var Occupation = DS.Model.extend({ description: DS.attr('string'), person: DS.belongsTo('person') @@ -613,7 +611,7 @@ test("findMany is passed the owner record for adapters when none of the object g var Person = DS.Model.extend({ name: DS.attr('string'), - occupations: DS.hasMany('occupation', { async: true }) + occupation: DS.belongsTo('occupation', { async: true }) }); Person.toString = function() { return "Person"; }; @@ -621,30 +619,14 @@ test("findMany is passed the owner record for adapters when none of the object g var env = setupStore({ occupation: Occupation, person: Person }), store = env.store; - env.adapter.findMany = function(store, type, ids, owner) { - equal(type, Occupation, "type should be Occupation"); - deepEqual(ids, ['5', '2'], "ids should be 5 and 2"); - equal(get(owner, 'id'), 1, "the owner record id should be 1"); - - return Ember.RSVP.resolve([{ id: 5, description: "fifth" }, { id: 2, description: "second" }]); + env.adapter.find = function(store, type, id, record) { + equal(record.get('person.id'), '1'); + return Ember.RSVP.resolve({ id: 5, description: "fifth" }); }; - env.adapter.find = function(store, type, id) { - equal(type, Person, "type should be Person"); - equal(id, 1, "id should be 1"); + store.push('person', { id: 1, name: "Tom Dale", occupation: 5 }); - return Ember.RSVP.resolve({ id: 1, name: "Tom Dale", occupations: [5, 2] }); - }; - - store.find('person', 1).then(async(function(person) { - equal(get(person, 'name'), "Tom Dale", "The person is now populated"); - - return get(person, 'occupations'); - })).then(async(function(occupations) { - equal(get(occupations, 'length'), 2, "the occupation objects still exist"); - equal(get(occupations.objectAt(0), 'description'), "fifth", "the occupation is the fifth"); - equal(get(occupations.objectAt(0), 'isLoaded'), true, "the occupation is now loaded"); - })); + store.getById('person', 1).get('occupation'); }); test("belongsTo supports relationships to models with id 0", function() { diff --git a/packages/ember-data/tests/unit/store/adapter_interop_test.js b/packages/ember-data/tests/unit/store/adapter_interop_test.js index e81edcb082a..ce634dbf0e5 100644 --- a/packages/ember-data/tests/unit/store/adapter_interop_test.js +++ b/packages/ember-data/tests/unit/store/adapter_interop_test.js @@ -34,14 +34,15 @@ test('Adapter can not be set as an instance', function() { }); test("Calling Store#find invokes its adapter#find", function() { - expect(4); + expect(5); var adapter = TestAdapter.extend({ - find: function(store, type, id) { + find: function(store, type, id, record) { ok(true, "Adapter#find was called"); equal(store, currentStore, "Adapter#find was called with the right store"); equal(type, currentType, "Adapter#find was called with the type passed into Store#find"); equal(id, 1, "Adapter#find was called with the id passed into Store#find"); + equal(record.get('id'), '1', "Adapter#find was called with the record created from Store#find"); return Ember.RSVP.resolve({ id: 1 }); } @@ -53,6 +54,31 @@ test("Calling Store#find invokes its adapter#find", function() { currentStore.find(currentType, 1); }); +test("Calling Store#findById multiple times coalesces the calls into a adapter#findMany call", function() { + expect(2); + + var adapter = TestAdapter.extend({ + find: function(store, type, id) { + ok(false, "Adapter#find was not called"); + }, + findMany: function(store, type, ids) { + start(); + ok(true, "Adapter#findMany was called"); + deepEqual(ids, ["1","2"], 'Correct ids were passed in to findMany'); + return Ember.RSVP.resolve([{ id: 1 }, { id:2}] ); + } + }); + + var currentStore = createStore({ adapter: adapter }); + var currentType = DS.Model.extend(); + currentType.typeKey = "test"; + stop(); + Ember.run(function(){ + currentStore.find(currentType, 1); + currentStore.find(currentType, 2); + }); +}); + test("Returning a promise from `find` asynchronously loads data", function() { var adapter = TestAdapter.extend({ find: function(store, type, id) { @@ -274,6 +300,122 @@ test("if an id is supplied in the initial data hash, it can be looked up using ` })); }); +test("initial values of attributes can be passed in as the third argument to find", function() { + var adapter = TestAdapter.extend({ + find: function(store, type, query) { + return new Ember.RSVP.Promise(function(){}); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var Person = DS.Model.extend({ + name: DS.attr('string') + }); + + + store.find(Person, 1, {name: 'Test'}); + equal(store.getById(Person, 1).get('name'), 'Test', 'Preloaded attribtue set'); +}); + +test("initial values of belongsTo can be passed in as the third argument to find as records", function() { + var adapter = TestAdapter.extend({ + find: function(store, type, query) { + return new Ember.RSVP.Promise(function(){}); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var Person = DS.Model.extend({ + name: DS.attr('string'), + friend: DS.belongsTo('person') + }); + + store.container.register('model:person', Person); + + var tom = store.push(Person, {id:2, name:'Tom'}); + + store.find(Person, 1, {friend: tom}); + + equal(store.getById(Person, 1).get('friend.name'), 'Tom', 'Preloaded belongsTo set'); +}); + +test("initial values of belongsTo can be passed in as the third argument to find as ids", function() { + var adapter = TestAdapter.extend({ + find: function(store, type, query) { + return new Ember.RSVP.Promise(function(){}); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var Person = DS.Model.extend({ + name: DS.attr('string'), + friend: DS.belongsTo('person') + }); + + store.container.register('model:person', Person); + + store.find(Person, 1, {friend: 2}); + + equal(store.getById(Person, 1).get('friend.id'), '2', 'Preloaded belongsTo set'); +}); + +test("initial values of hasMany can be passed in as the third argument to find as records", function() { + var adapter = TestAdapter.extend({ + find: function(store, type, query) { + return new Ember.RSVP.Promise(function(){}); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var Person = DS.Model.extend({ + name: DS.attr('string'), + friends: DS.hasMany('person') + }); + + store.container.register('model:person', Person); + + var tom = store.push(Person, {id:2, name:'Tom'}); + + store.find(Person, 1, {friends: [tom]}); + + equal(store.getById(Person, 1).get('friends').toArray()[0].get('name'), 'Tom', 'Preloaded hasMany set'); +}); + +test("initial values of hasMany can be passed in as the third argument to find as ids", function() { + var adapter = TestAdapter.extend({ + find: function(store, type, query) { + return new Ember.RSVP.Promise(function(){}); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var Person = DS.Model.extend({ + name: DS.attr('string'), + friends: DS.hasMany('person') + }); + + store.container.register('model:person', Person); + + store.find(Person, 1, {friends: [2]}); + + equal(store.getById(Person, 1).get('friends').toArray()[0].get('id'), '2', 'Preloaded hasMany set'); +}); + test("records inside a collection view should have their ids updated", function() { var Person = DS.Model.extend(); @@ -314,22 +456,34 @@ test("store.fetchMany should always return a promise", function() { var owner = store.createRecord(Person); var records = Ember.A([]); - var results = store.fetchMany(records, owner); - ok(results, "A call to store.fetchMany() should return a result"); - ok(results.then, "A call to store.fetchMany() should return a promise"); + var results = store.scheduleFetchMany(records); + ok(results, "A call to store.scheduleFetchMany() should return a result"); + ok(results.then, "A call to store.scheduleFetchMany() should return a promise"); results.then(async(function(returnedRecords) { - equal(returnedRecords, records, "The empty record sets should match"); + deepEqual(returnedRecords, [], "The correct records are returned"); })); }); -test("store.fetchMany should not resolve until all the records are resolve", function() { +test("store.scheduleFetchMany should not resolve until all the records are resolve", function() { var Person = DS.Model.extend(); var Phone = DS.Model.extend(); var adapter = TestAdapter.extend({ + find: function (store, type, id) { + var wait = 5; + + var record = { id: id }; + + return new Ember.RSVP.Promise(function(resolve, reject) { + Ember.run.later(function() { + resolve(record); + }, wait); + }); + }, + findMany: function(store, type, ids) { - var wait = (type === Person)? 5 : 15; + var wait = 15; var records = ids.map(function(id) { return {id: id}; @@ -355,8 +509,186 @@ test("store.fetchMany should not resolve until all the records are resolve", fun store.recordForId(Phone, 21) ]); - store.fetchMany(records, owner).then(async(function() { + store.scheduleFetchMany(records).then(async(function() { var unloadedRecords = records.filterBy('isEmpty'); equal(get(unloadedRecords, 'length'), 0, 'All unloaded records should be loaded'); })); }); + +test("the store calls adapter.findMany according to groupings returned by adapter.groupRecordsForFindMany", function() { + expect(3); + + var callCount = 0; + var Person = DS.Model.extend(); + + var adapter = TestAdapter.extend({ + groupRecordsForFindMany: function (store, records) { + return [ + [records[0]], + [records[1], records[2]] + ]; + }, + + find: function(store, type, id) { + equal(id, "10", "The first group is passed to find"); + return Ember.RSVP.resolve({id:id}); + }, + + findMany: function(store, type, ids) { + var records = ids.map(function(id) { + return {id: id}; + }); + + deepEqual(ids, ["20", "21"], "The second group is passed to findMany"); + + return new Ember.RSVP.Promise(function(resolve, reject) { + resolve(records); + }); + } + }); + + var store = createStore({ + adapter: adapter + }); + + var records = Ember.A([ + store.recordForId(Person, 10), + store.recordForId(Person, 20), + store.recordForId(Person, 21) + ]); + + store.scheduleFetchMany(records).then(async(function() { + var ids = records.mapBy('id'); + deepEqual(ids, ["10", "20", "21"], "The promise fulfills with the records"); + })); +}); + +test("the promise returned by `scheduleFetch`, when it resolves, does not depend on the promises returned to other calls to `scheduleFetch` that are in the same run loop, but different groups", function() { + expect(2); + var Person = DS.Model.extend(); + var davidResolved = false; + + var adapter = TestAdapter.extend({ + groupRecordsForFindMany: function (store, records) { + return [ + [records[0]], + [records[1]] + ]; + }, + + find: function(store, type, id) { + var record = {id: id}; + + return new Ember.RSVP.Promise(function(resolve, reject) { + if (id === 'igor') { + resolve(record); + } else { + Ember.run.later(function () { + davidResolved = true; + resolve(record); + }, 5); + } + }); + } + }); + + var store = createStore({ + adapter: adapter + }); + + Ember.run(function () { + var davidPromise = store.find(Person, 'david'); + var igorPromise = store.find(Person, 'igor'); + + igorPromise.then(async(function () { + equal(davidResolved, false, "Igor did not need to wait for David"); + })); + + davidPromise.then(async(function () { + equal(davidResolved, true, "David resolved"); + })); + }); +}); + +test("the promise returned by `scheduleFetch`, when it rejects, does not depend on the promises returned to other calls to `scheduleFetch` that are in the same run loop, but different groups", function() { + expect(2); + var Person = DS.Model.extend(); + var davidResolved = false; + + var adapter = TestAdapter.extend({ + groupRecordsForFindMany: function (store, records) { + return [ + [records[0]], + [records[1]] + ]; + }, + + find: function(store, type, id) { + var record = {id: id}; + + return new Ember.RSVP.Promise(function(resolve, reject) { + if (id === 'igor') { + reject(record); + } else { + Ember.run.later(function () { + davidResolved = true; + resolve(record); + }, 5); + } + }); + } + }); + + var store = createStore({ + adapter: adapter + }); + + Ember.run(function () { + var davidPromise = store.find(Person, 'david'); + var igorPromise = store.find(Person, 'igor'); + + igorPromise.then(null, async(function () { + equal(davidResolved, false, "Igor did not need to wait for David"); + })); + + davidPromise.then(async(function () { + equal(davidResolved, true, "David resolved"); + })); + }); +}); + +test("store.fetchRecord reject records that were not found, even when those requests were coalesced with records that were found", function() { + expect(2); + var Person = DS.Model.extend(); + + var adapter = TestAdapter.extend({ + findMany: function(store, type, ids) { + var records = ids.map(function(id) { + return {id: id}; + }); + + return new Ember.RSVP.Promise(function(resolve, reject) { + resolve([ + records[0] + ]); + }); + } + }); + + var store = createStore({ + adapter: adapter + }); + + Ember.run(function () { + var davidPromise = store.find(Person, 'david'); + var igorPromise = store.find(Person, 'igor'); + + davidPromise.then(async(function () { + ok(true, "David resolved"); + })); + + igorPromise.then(null, async(function () { + ok(true, "Igor rejected"); + })); + }); +}); diff --git a/packages/ember-data/tests/unit/store/unload_test.js b/packages/ember-data/tests/unit/store/unload_test.js index a03848ed8bb..fc274e4b5a2 100644 --- a/packages/ember-data/tests/unit/store/unload_test.js +++ b/packages/ember-data/tests/unit/store/unload_test.js @@ -55,8 +55,9 @@ test("unload a record", function() { equal(get(record, 'isDeleted'), true, "record is deleted"); tryToFind = false; - store.find(Record, 1); - equal(tryToFind, true, "not found record with id 1"); + store.find(Record, 1).then(async(function(){ + equal(tryToFind, true, "not found record with id 1"); + })); })); }); diff --git a/tests/ember_configuration.js b/tests/ember_configuration.js index 07d4647adf7..fe57ce2f218 100644 --- a/tests/ember_configuration.js +++ b/tests/ember_configuration.js @@ -158,7 +158,9 @@ didUpdateAttribute: syncForTest(), didUpdateAttributes: syncForTest(), didUpdateRelationship: syncForTest(), - didUpdateRelationships: syncForTest() + didUpdateRelationships: syncForTest(), + scheduleFetch: syncForTest(), + scheduleFetchMany: syncForTest() }); DS.Model.reopen({