diff --git a/addon/-private/system/model/internal-model.js b/addon/-private/system/model/internal-model.js index 42b01cbd255..d8f99b67a17 100644 --- a/addon/-private/system/model/internal-model.js +++ b/addon/-private/system/model/internal-model.js @@ -373,11 +373,13 @@ export default class InternalModel { break; case 'belongsTo': this.setDirtyBelongsTo(name, propertyValue); - relationships.get(name).setHasData(true); + relationships.get(name).setHasAnyRelationshipData(true); + relationships.get(name).setRelationshipIsEmpty(false); break; case 'hasMany': this.setDirtyHasMany(name, propertyValue); - relationships.get(name).setHasData(true); + relationships.get(name).setHasAnyRelationshipData(true); + relationships.get(name).setRelationshipIsEmpty(false); break; default: createOptions[name] = propertyValue; diff --git a/addon/-private/system/promise-proxies.js b/addon/-private/system/promise-proxies.js index f2a6bb814a8..a740d003c6b 100644 --- a/addon/-private/system/promise-proxies.js +++ b/addon/-private/system/promise-proxies.js @@ -1,7 +1,7 @@ import ObjectProxy from '@ember/object/proxy'; import PromiseProxyMixin from '@ember/object/promise-proxy-mixin'; import ArrayProxy from '@ember/array/proxy'; -import { get } from '@ember/object'; +import { get, computed } from '@ember/object'; import { reads } from '@ember/object/computed'; import { Promise } from 'rsvp'; import { assert } from '@ember/debug'; @@ -82,6 +82,28 @@ export function promiseArray(promise, label) { }); } +export const PromiseBelongsTo = PromiseObject.extend({ + + // we don't proxy meta because we would need to proxy it to the relationship state container + // however, meta on relationships does not trigger change notifications. + // if you need relationship meta, you should do `record.belongsTo(relationshipName).meta()` + meta: computed(function() { + assert( + 'You attempted to access meta on the promise for the async belongsTo relationship ' + + `${this.get('_belongsToState').internalModel.modelName}:${this.get('_belongsToState').key}'.` + + '\nUse `record.belongsTo(relationshipName).meta()` instead.', + false + ); + }), + + reload() { + assert('You are trying to reload an async belongsTo before it has been created', this.get('content') !== undefined); + this.get('_belongsToState').reload(); + + return this; + } +}); + /** A PromiseManyArray is a PromiseArray that also proxies certain method calls to the underlying manyArray. @@ -109,7 +131,7 @@ export function proxyToContent(method) { export const PromiseManyArray = PromiseArray.extend({ reload() { assert('You are trying to reload an async manyArray before it has been created', get(this, 'content')); - this.set('promise', this.get('content').reload()) + this.set('promise', this.get('content').reload()); return this; }, diff --git a/addon/-private/system/references/has-many.js b/addon/-private/system/references/has-many.js index 9af516d6d48..5e88af85754 100644 --- a/addon/-private/system/references/has-many.js +++ b/addon/-private/system/references/has-many.js @@ -258,8 +258,8 @@ export default class HasManyReference extends Reference { } _isLoaded() { - let hasData = get(this.hasManyRelationship, 'hasData'); - if (!hasData) { + let hasRelationshipDataProperty = get(this.hasManyRelationship, 'hasAnyRelationshipData'); + if (!hasRelationshipDataProperty) { return false; } diff --git a/addon/-private/system/relationships/relationship-payloads.js b/addon/-private/system/relationships/relationship-payloads.js index 4233d461cbe..e82d5b7bcc5 100644 --- a/addon/-private/system/relationships/relationship-payloads.js +++ b/addon/-private/system/relationships/relationship-payloads.js @@ -1,5 +1,41 @@ import { assert } from '@ember/debug'; +/** + * Merge data,meta,links information forward to the next payload + * if required. Latest data will always win. + * + * @param oldPayload + * @param newPayload + */ +function mergeForwardPayload(oldPayload, newPayload) { + if (oldPayload && oldPayload.data !== undefined && newPayload.data === undefined) { + newPayload.data = oldPayload.data; + } + + /* + _partialData is has-many relationship data that has been discovered via + inverses in the absence of canonical `data` availability from the primary + payload. + + We can't merge this data into `data` as that would trick has-many relationships + into believing they know their complete membership. Anytime we find canonical + data from the primary record, this partial data is discarded. If no canonical + data is ever discovered, the partial data will be loaded by the relationship + in a way that correctly preserves the `stale` relationship state. + */ + if (newPayload.data === undefined && oldPayload && oldPayload._partialData !== undefined) { + newPayload._partialData = oldPayload._partialData; + } + + if (oldPayload && oldPayload.meta !== undefined && newPayload.meta === undefined) { + newPayload.meta = oldPayload.meta; + } + + if (oldPayload && oldPayload.links !== undefined && newPayload.links === undefined) { + newPayload.links = oldPayload.links; + } +} + // TODO this is now VERY similar to the identity/internal-model map // so we should probably generalize export class TypeCache { @@ -204,7 +240,6 @@ export default class RelationshipPayloads { let payloadMap; let inversePayloadMap; let inverseIsMany; - if (this._isLHS(modelName, relationshipName)) { previousPayload = this.lhs_payloads.get(modelName, id); payloadMap = this.lhs_payloads; @@ -257,14 +292,34 @@ export default class RelationshipPayloads { // * null is considered new information "empty", and it should win // * undefined is NOT considered new information, we should keep original state // * anything else is considered new information, and it should win + let isMatchingIdentifier = this._isMatchingIdentifier( + relationshipData && relationshipData.data, + previousPayload && previousPayload.data + ); + if (relationshipData.data !== undefined) { - this._removeInverse(id, previousPayload, inversePayloadMap); + if (!isMatchingIdentifier) { + this._removeInverse(id, previousPayload, inversePayloadMap); + } } + + mergeForwardPayload(previousPayload, relationshipData); payloadMap.set(modelName, id, relationshipData); - this._populateInverse(relationshipData, inverseRelationshipData, inversePayloadMap, inverseIsMany); + + if (!isMatchingIdentifier) { + this._populateInverse(relationshipData, inverseRelationshipData, inversePayloadMap, inverseIsMany); + } } } + _isMatchingIdentifier(a, b) { + return a && b && + a.type === b.type && + a.id === b.id && + !Array.isArray(a) && + !Array.isArray(b); + } + /** Populate the inverse relationship for `relationshipData`. @@ -306,22 +361,33 @@ export default class RelationshipPayloads { */ _addToInverse(inversePayload, resourceIdentifier, inversePayloadMap, inverseIsMany) { let relInfo = this._relInfo; + let inverseData = inversePayload.data; - if (relInfo.isReflexive && inversePayload.data.id === resourceIdentifier.id) { + if (relInfo.isReflexive && inverseData && inverseData.id === resourceIdentifier.id) { // eg .friends = [{ id: 1, type: 'user' }] return; } let existingPayload = inversePayloadMap.get(resourceIdentifier.type, resourceIdentifier.id); - let existingData = existingPayload && existingPayload.data; - if (existingData) { - // There already is an inverse, either add or overwrite depehnding on + if (existingPayload) { + // There already is an inverse, either add or overwrite depending on // whether the inverse is a many relationship or not // - if (Array.isArray(existingData)) { - existingData.push(inversePayload.data); + if (inverseIsMany) { + let existingData = existingPayload.data; + + // in the case of a hasMany + // we do not want create a `data` array where there was none before + // if we also have links, which this would indicate + if (existingData) { + existingData.push(inversePayload.data); + } else { + existingPayload._partialData = existingPayload._partialData || []; + existingPayload._partialData.push(inversePayload.data); + } } else { + mergeForwardPayload(existingPayload, inversePayload); inversePayloadMap.set(resourceIdentifier.type, resourceIdentifier.id, inversePayload); } } else { @@ -329,7 +395,7 @@ export default class RelationshipPayloads { // if (inverseIsMany) { inversePayloadMap.set(resourceIdentifier.type, resourceIdentifier.id, { - data: [inversePayload.data] + _partialData: [inversePayload.data] }); } else { inversePayloadMap.set(resourceIdentifier.type, resourceIdentifier.id, inversePayload); @@ -357,7 +423,10 @@ export default class RelationshipPayloads { */ _removeInverse(id, previousPayload, inversePayloadMap) { let data = previousPayload && previousPayload.data; - if (!data) { + let partialData = previousPayload && previousPayload._partialData; + let maybeData = data || partialData; + + if (!maybeData) { // either this is the first time we've seen a payload for this id, or its // previous payload indicated that it had no inverse, eg a belongsTo // relationship with payload { data: null } @@ -367,10 +436,10 @@ export default class RelationshipPayloads { return; } - if (Array.isArray(data)) { + if (Array.isArray(maybeData)) { // TODO: diff rather than removeall addall? - for (let i=0; i x.id !== id); + } else if (Array.isArray(partialData)) { + inversePayload._partialData = partialData.filter((x) => x.id !== id); } else { - inversePayloads.set(resourceIdentifier.type, resourceIdentifier.id, { - data: null - }); + // this merges forward links and meta + inversePayload.data = null; } } } diff --git a/addon/-private/system/relationships/state/belongs-to.js b/addon/-private/system/relationships/state/belongs-to.js index ea3bda37abe..a95c2692c6b 100644 --- a/addon/-private/system/relationships/state/belongs-to.js +++ b/addon/-private/system/relationships/state/belongs-to.js @@ -2,17 +2,16 @@ import { Promise as EmberPromise } from 'rsvp'; import { assert, inspect } from '@ember/debug'; import { assertPolymorphicType } from 'ember-data/-debug'; import { - PromiseObject + PromiseBelongsTo } from "../../promise-proxies"; import Relationship from "./relationship"; export default class BelongsToRelationship extends Relationship { constructor(store, internalModel, inverseKey, relationshipMeta) { super(store, internalModel, inverseKey, relationshipMeta); - this.internalModel = internalModel; - this.key = relationshipMeta.key; this.inverseInternalModel = null; this.canonicalState = null; + this._loadingPromise = null; } setInternalModel(internalModel) { @@ -21,8 +20,10 @@ export default class BelongsToRelationship extends Relationship { } else if (this.inverseInternalModel) { this.removeInternalModel(this.inverseInternalModel); } - this.setHasData(true); - this.setHasLoaded(true); + this.setHasAnyRelationshipData(true); + this.setRelationshipIsStale(false); + this.setRelationshipIsEmpty(false); + this.setHasRelatedResources(!this.localStateIsEmpty()); } setCanonicalInternalModel(internalModel) { @@ -165,20 +166,16 @@ export default class BelongsToRelationship extends Relationship { //TODO(Igor) flushCanonical here once our syncing is not stupid if (this.isAsync) { let promise; - if (this.link) { - if (this.hasLoaded) { - promise = this.findRecord(); - } else { - promise = this.findLink().then(() => this.findRecord()); - } + + if (this._shouldFindViaLink()) { + promise = this.findLink().then(() => this.findRecord()); } else { promise = this.findRecord(); } - return PromiseObject.create({ - promise: promise, - content: this.inverseInternalModel ? this.inverseInternalModel.getRecord() : null - }); + let record = this.inverseInternalModel ? this.inverseInternalModel.getRecord() : null + + return this._updateLoadingPromise(promise, record); } else { if (this.inverseInternalModel === null) { return null; @@ -189,19 +186,50 @@ export default class BelongsToRelationship extends Relationship { } } + _updateLoadingPromise(promise, content) { + if (this._loadingPromise) { + if (content) { + this._loadingPromise.set('content', content) + } + this._loadingPromise.set('promise', promise) + } else { + this._loadingPromise = PromiseBelongsTo.create({ + _belongsToState: this, + promise, + content + }); + } + + return this._loadingPromise; + } + reload() { - // TODO handle case when reload() is triggered multiple times + // we've already fired off a request + if (this._loadingPromise) { + if (this._loadingPromise.get('isPending')) { + return this._loadingPromise; + } + } + + let promise; + this.setRelationshipIsStale(true); if (this.link) { - return this.fetchLink(); + promise = this.fetchLink(); + } else if (this.inverseInternalModel && this.inverseInternalModel.hasRecord) { + // reload record, if it is already loaded + promise = this.inverseInternalModel.getRecord().reload(); + } else { + promise = this.findRecord(); } - // reload record, if it is already loaded - if (this.inverseInternalModel && this.inverseInternalModel.hasRecord) { - return this.inverseInternalModel.getRecord().reload(); - } + return this._updateLoadingPromise(promise); + } + + localStateIsEmpty() { + let internalModel = this.inverseInternalModel; - return this.findRecord(); + return !internalModel || internalModel.isEmpty(); } updateData(data, initial) { diff --git a/addon/-private/system/relationships/state/has-many.js b/addon/-private/system/relationships/state/has-many.js index edd6d60c8e8..6a73365e369 100755 --- a/addon/-private/system/relationships/state/has-many.js +++ b/addon/-private/system/relationships/state/has-many.js @@ -16,26 +16,25 @@ export default class ManyRelationship extends Relationship { // we create a new many array, but in the interim it will be updated if // inverse internal models are unloaded. this._retainedManyArray = null; - this.__loadingPromise = null; + this._loadingPromise = null; this._willUpdateManyArray = false; this._pendingManyArrayUpdates = null; } - get _loadingPromise() { return this.__loadingPromise; } _updateLoadingPromise(promise, content) { - if (this.__loadingPromise) { + if (this._loadingPromise) { if (content) { - this.__loadingPromise.set('content', content) + this._loadingPromise.set('content', content) } - this.__loadingPromise.set('promise', promise) + this._loadingPromise.set('promise', promise) } else { - this.__loadingPromise = PromiseManyArray.create({ + this._loadingPromise = PromiseManyArray.create({ promise, content }); } - return this.__loadingPromise; + return this._loadingPromise; } get manyArray() { @@ -246,17 +245,15 @@ export default class ManyRelationship extends Relationship { reload() { let manyArray = this.manyArray; - let manyArrayLoadedState = manyArray.get('isLoaded'); if (this._loadingPromise) { if (this._loadingPromise.get('isPending')) { return this._loadingPromise; } - if (this._loadingPromise.get('isRejected')) { - manyArray.set('isLoaded', manyArrayLoadedState); - } } + this.setRelationshipIsStale(true); + let promise; if (this.link) { promise = this.fetchLink(); @@ -315,7 +312,6 @@ export default class ManyRelationship extends Relationship { this.store._backburner.join(() => { this.updateInternalModelsFromAdapter(records); this.manyArray.set('isLoaded', true); - this.setHasData(true); }); return this.manyArray; }); @@ -342,26 +338,22 @@ export default class ManyRelationship extends Relationship { getRecords() { //TODO(Igor) sync server here, once our syncing is not stupid let manyArray = this.manyArray; + if (this.isAsync) { let promise; - if (this.link) { - if (this.hasLoaded) { - promise = this.findRecords(); - } else { - promise = this.findLink().then(() => this.findRecords()); - } + + if (this._shouldFindViaLink()) { + promise = this.findLink().then(() => this.findRecords()); } else { promise = this.findRecords(); } + return this._updateLoadingPromise(promise, manyArray); } else { assert(`You looked up the '${this.key}' relationship on a '${this.internalModel.type.modelName}' with id ${this.internalModel.id} but some of the associated records were not loaded. Either make sure they are all loaded together with the parent record, or specify that the relationship is async ('DS.hasMany({ async: true })')`, manyArray.isEvery('isEmpty', false)); - //TODO(Igor) WTF DO I DO HERE? - // TODO @runspired equal WTFs to Igor - if (!manyArray.get('isDestroyed')) { - manyArray.set('isLoaded', true); - } + manyArray.set('isLoaded', true); + return manyArray; } } @@ -375,6 +367,20 @@ export default class ManyRelationship extends Relationship { } } + localStateIsEmpty() { + let manyArray = this.manyArray; + let internalModels = manyArray.currentState; + let manyArrayIsLoaded = manyArray.get('isLoaded'); + + if (!manyArrayIsLoaded && internalModels.length) { + manyArrayIsLoaded = internalModels.reduce((hasNoEmptyModel, i) => { + return hasNoEmptyModel && !i.isEmpty(); + }, true); + } + + return !manyArrayIsLoaded; + } + destroy() { super.destroy(); let manyArray = this._manyArray; @@ -383,11 +389,11 @@ export default class ManyRelationship extends Relationship { this._manyArray = null; } - let proxy = this.__loadingPromise; + let proxy = this._loadingPromise; if (proxy) { proxy.destroy(); - this.__loadingPromise = null; + this._loadingPromise = null; } } } diff --git a/addon/-private/system/relationships/state/relationship.js b/addon/-private/system/relationships/state/relationship.js index 2760a9ac822..4d2bef4c5a4 100644 --- a/addon/-private/system/relationships/state/relationship.js +++ b/addon/-private/system/relationships/state/relationship.js @@ -25,8 +25,6 @@ const { removeInternalModelFromInverse, removeInternalModelFromOwn, removeInternalModels, - setHasData, - setHasLoaded, updateLink, updateMeta, updateInternalModelsFromAdapter @@ -49,8 +47,6 @@ const { 'removeInternalModelFromInverse', 'removeInternalModelFromOwn', 'removeInternalModels', - 'setHasData', - 'setHasLoaded', 'updateLink', 'updateMeta', 'updateInternalModelsFromAdapter' @@ -75,19 +71,81 @@ export default class Relationship { this.inverseKeyForImplicit = this.internalModel.modelName + this.key; this.linkPromise = null; this.meta = null; - this.hasData = false; - this.hasLoaded = false; this.__inverseMeta = undefined; - } - _inverseIsAsync() { - let inverseMeta = this._inverseMeta; - if (!inverseMeta) { - return false; - } + /* + This flag indicates whether we should + re-fetch the relationship the next time + it is accessed. + + false when + => internalModel.isNew() on initial setup + => a previously triggered request has resolved + => we get relationship data via push + + true when + => !internalModel.isNew() on initial setup + => an inverse has been unloaded + => relationship.reload() has been called + => we get a new link for the relationship + */ + this.relationshipIsStale = !internalModel.isNew(); - let inverseAsync = inverseMeta.options.async; - return typeof inverseAsync === 'undefined' ? true : inverseAsync; + /* + This flag indicates whether we should consider the content + of this relationship "known". + + If we have no relationship knowledge, and the relationship + is `async`, we will attempt to fetch the relationship on + access if it is also stale. + + Snapshot uses this to tell the difference between unknown + (`undefined`) or empty (`null`). The reason for this is that + we wouldn't want to serialize unknown relationships as `null` + as that might overwrite remote state. + + All relationships for a newly created (`store.createRecord()`) are + considered known (`hasAnyRelationshipData === true`). + + true when + => we receive a push with either new data or explicit empty (`[]` or `null`) + => the relationship is a belongsTo and we have received data from + the other side. + + false when + => we have received no signal about what data belongs in this relationship + => the relationship is a hasMany and we have only received data from + the other side. + */ + this.hasAnyRelationshipData = false; + + /* + Flag that indicates whether an empty relationship is explicitly empty + (signaled by push giving us an empty array or null relationship) + e.g. an API response has told us that this relationship is empty. + + Thus far, it does not appear that we actually need this flag; however, + @runspired has found it invaluable when debugging relationship tests + to determine whether (and why if so) we are in an incorrect state. + + true when + => we receive a push with explicit empty (`[]` or `null`) + => we have received no signal about what data belongs in this relationship + => on initial create (as no signal is known yet) + + false at all other times + */ + this.relationshipIsEmpty = true; + + /* + true when + => hasAnyRelationshipData is true + AND + => members (NOT canonicalMembers) @each !isEmpty + + TODO, consider changing the conditional here from !isEmpty to !hiddenFromRecordArrays + */ + this.hasRelatedResources = false; } _inverseIsSync() { @@ -131,6 +189,8 @@ export default class Relationship { inverseDidDematerialize(inverseInternalModel) { this.linkPromise = null; + this.setRelationshipIsStale(true); + if (!this.isAsync) { // unloading inverse of a sync relationship is treated as a client-side // delete, so actually remove the models don't merely invalidate the cp @@ -204,7 +264,7 @@ export default class Relationship { this.setupInverseRelationship(internalModel); } this.flushCanonicalLater(); - this.setHasData(true); + this.setHasAnyRelationshipData(true); } setupInverseRelationship(internalModel) { @@ -277,7 +337,7 @@ export default class Relationship { } this.internalModel.updateRecordArrays(); } - this.setHasData(true); + this.setHasAnyRelationshipData(true); } removeInternalModel(internalModel) { @@ -423,19 +483,29 @@ export default class Relationship { updateLink(link, initial) { heimdall.increment(updateLink); - warn(`You pushed a record of type '${this.internalModel.modelName}' with a relationship '${this.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload.`, this.isAsync || this.hasData , { + warn(`You pushed a record of type '${this.internalModel.modelName}' with a relationship '${this.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload.`, this.isAsync || this.hasAnyRelationshipData , { id: 'ds.store.push-link-for-sync-relationship' }); assert(`You have pushed a record of type '${this.internalModel.modelName}' with '${this.key}' as a link, but the value of that link is not a string.`, typeof link === 'string' || link === null); this.link = link; this.linkPromise = null; + this.setRelationshipIsStale(true); if (!initial) { this.internalModel.notifyPropertyChange(this.key); } } + _shouldFindViaLink() { + if (!this.link) { + return false; + } + + return this.relationshipIsStale || + !this.hasRelatedResources; + } + findLink() { heimdall.increment(findLink); if (this.linkPromise) { @@ -449,7 +519,7 @@ export default class Relationship { updateInternalModelsFromAdapter(internalModels) { heimdall.increment(updateInternalModelsFromAdapter); - this.setHasData(true); + this.setHasAnyRelationshipData(true); //TODO(Igor) move this to a proper place //TODO Once we have adapter support, we need to handle updated and canonical changes this.computeChanges(internalModels); @@ -457,34 +527,20 @@ export default class Relationship { notifyRecordRelationshipAdded() { } - /* - `hasData` for a relationship is a flag to indicate if we consider the - content of this relationship "known". Snapshots uses this to tell the - difference between unknown (`undefined`) or empty (`null`). The reason for - this is that we wouldn't want to serialize unknown relationships as `null` - as that might overwrite remote state. - - All relationships for a newly created (`store.createRecord()`) are - considered known (`hasData === true`). - */ - setHasData(value) { - heimdall.increment(setHasData); - this.hasData = value; + setHasAnyRelationshipData(value) { + this.hasAnyRelationshipData = value; } - /* - `hasLoaded` is a flag to indicate if we have gotten data from the adapter or - not when the relationship has a link. + setHasRelatedResources(v) { + this.hasRelatedResources = v; + } - This is used to be able to tell when to fetch the link and when to return - the local data in scenarios where the local state is considered known - (`hasData === true`). + setRelationshipIsStale(value) { + this.relationshipIsStale = value; + } - Updating the link will automatically set `hasLoaded` to `false`. - */ - setHasLoaded(value) { - heimdall.increment(setHasLoaded); - this.hasLoaded = value; + setRelationshipIsEmpty(value) { + this.relationshipIsEmpty = value; } /* @@ -498,7 +554,7 @@ export default class Relationship { push(payload, initial) { heimdall.increment(push); - let hasData = false; + let hasRelationshipDataProperty = false; let hasLink = false; if (payload.meta) { @@ -506,8 +562,10 @@ export default class Relationship { } if (payload.data !== undefined) { - hasData = true; + hasRelationshipDataProperty = true; this.updateData(payload.data, initial); + } else if (payload._partialData !== undefined) { + this.updateData(payload._partialData, initial); } if (payload.links && payload.links.related) { @@ -522,19 +580,29 @@ export default class Relationship { Data being pushed into the relationship might contain only data or links, or a combination of both. - If we got data we want to set both hasData and hasLoaded to true since - this would indicate that we should prefer the local state instead of - trying to fetch the link or call findRecord(). + IF contains only data + IF contains both links and data + relationshipIsEmpty -> true if is empty array (has-many) or is null (belongs-to) + hasAnyRelationshipData -> true + relationshipIsStale -> false + hasRelatedResources -> run-check-to-determine - If we have no data but a link is present we want to set hasLoaded to false - without modifying the hasData flag. This will ensure we fetch the updated - link next time the relationship is accessed. + IF contains only links + relationshipIsStale -> true */ - if (hasData) { - this.setHasData(true); - this.setHasLoaded(true); + + if (hasRelationshipDataProperty) { + let relationshipIsEmpty = payload.data === null || + (Array.isArray(payload.data) && payload.data.length === 0); + + this.setHasAnyRelationshipData(true); + this.setRelationshipIsStale(false); + this.setRelationshipIsEmpty(relationshipIsEmpty); + this.setHasRelatedResources( + relationshipIsEmpty || !this.localStateIsEmpty() + ); } else if (hasLink) { - this.setHasLoaded(false); + this.setRelationshipIsStale(true); } } diff --git a/addon/-private/system/snapshot.js b/addon/-private/system/snapshot.js index adfc8cd1df1..e6b04c8b20f 100644 --- a/addon/-private/system/snapshot.js +++ b/addon/-private/system/snapshot.js @@ -198,7 +198,7 @@ export default class Snapshot { */ belongsTo(keyName, options) { let id = options && options.id; - let relationship, inverseInternalModel, hasData; + let relationship; let result; if (id && keyName in this._belongsToIds) { @@ -214,10 +214,12 @@ export default class Snapshot { throw new EmberError("Model '" + inspect(this.record) + "' has no belongsTo relationship named '" + keyName + "' defined."); } - hasData = get(relationship, 'hasData'); - inverseInternalModel = get(relationship, 'inverseInternalModel'); + let { + hasAnyRelationshipData, + inverseInternalModel + } = relationship; - if (hasData) { + if (hasAnyRelationshipData) { if (inverseInternalModel && !inverseInternalModel.isDeleted()) { if (id) { result = get(inverseInternalModel, 'id'); @@ -269,7 +271,7 @@ export default class Snapshot { */ hasMany(keyName, options) { let ids = options && options.ids; - let relationship, members, hasData; + let relationship; let results; if (ids && keyName in this._hasManyIds) { @@ -285,10 +287,12 @@ export default class Snapshot { throw new EmberError("Model '" + inspect(this.record) + "' has no hasMany relationship named '" + keyName + "' defined."); } - hasData = get(relationship, 'hasData'); - members = get(relationship, 'members'); + let { + hasAnyRelationshipData, + members + } = relationship; - if (hasData) { + if (hasAnyRelationshipData) { results = []; members.forEach((member) => { if (!member.isDeleted()) { diff --git a/tests/integration/adapter/json-api-adapter-test.js b/tests/integration/adapter/json-api-adapter-test.js index 6443d7d4910..c945eeb51cf 100644 --- a/tests/integration/adapter/json-api-adapter-test.js +++ b/tests/integration/adapter/json-api-adapter-test.js @@ -59,7 +59,7 @@ module('integration/adapter/json-api-adapter - JSONAPIAdapter', { }); env = setupStore({ - adapter: DS.JSONAPIAdapter, + adapter: DS.JSONAPIAdapter.extend(), 'user': User, 'post': Post, diff --git a/tests/integration/records/rematerialize-test.js b/tests/integration/records/rematerialize-test.js index 416e299f24c..76dfdaba91c 100644 --- a/tests/integration/records/rematerialize-test.js +++ b/tests/integration/records/rematerialize-test.js @@ -1,38 +1,36 @@ /*eslint no-unused-vars: ["error", { "varsIgnorePattern": "(adam|bob|dudu)" }]*/ import { run } from '@ember/runloop'; - +import Ember from 'ember'; import setupStore from 'dummy/tests/helpers/store'; - import { module, test } from 'qunit'; - import DS from 'ember-data'; -let attr = DS.attr; -let belongsTo = DS.belongsTo; -let hasMany = DS.hasMany; +const { copy } = Ember; +const { attr, belongsTo, hasMany, Model } = DS; + let env; -let Person = DS.Model.extend({ +let Person = Model.extend({ name: attr('string'), cars: hasMany('car', { async: false }), boats: hasMany('boat', { async: true }) }); Person.reopenClass({ toString() { return 'Person'; } }); -let Group = DS.Model.extend({ +let Group = Model.extend({ people: hasMany('person', { async: false }) }); Group.reopenClass({ toString() { return 'Group'; } }); -let Car = DS.Model.extend({ +let Car = Model.extend({ make: attr('string'), model: attr('string'), person: belongsTo('person', { async: false }) }); Car.reopenClass({ toString() { return 'Car'; } }); -let Boat = DS.Model.extend({ +let Boat = Model.extend({ name: attr('string'), person: belongsTo('person', { async: false }) }); @@ -139,7 +137,7 @@ test("a sync belongs to relationship to an unloaded record can restore that reco }); test("an async has many relationship to an unloaded record can restore that record", function(assert) { - assert.expect(15); + assert.expect(16); // disable background reloading so we do not re-create the relationship. env.adapter.shouldBackgroundReloadRecord = () => false; @@ -175,9 +173,9 @@ test("an async has many relationship to an unloaded record can restore that reco let data; if (param === '1') { - data = BOAT_ONE; + data = copy(BOAT_ONE, true); } else if (param === '1') { - data = BOAT_TWO; + data = copy(BOAT_TWO, true); } else { throw new Error(`404: no such boat with id=${param}`); } @@ -185,7 +183,7 @@ test("an async has many relationship to an unloaded record can restore that reco return { data }; - } + }; run(() => { env.store.push({ @@ -209,17 +207,20 @@ test("an async has many relationship to an unloaded record can restore that reco run(() => { env.store.push({ - data: [BOAT_ONE, BOAT_TWO] + data: [ + copy(BOAT_ONE, true), + copy(BOAT_TWO, true) + ] }); }); - let adam = env.store.peekRecord('person', 1); - let boaty = env.store.peekRecord('boat', 1); + let adam = env.store.peekRecord('person', '1'); + let boaty = env.store.peekRecord('boat', '1'); - assert.equal(env.store.hasRecordForId('person', 1), true, 'The person is in the store'); - assert.equal(env.store._internalModelsFor('person').has(1), true, 'The person internalModel is loaded'); - assert.equal(env.store.hasRecordForId('boat', 1), true, 'The boat is in the store'); - assert.equal(env.store._internalModelsFor('boat').has(1), true, 'The boat internalModel is loaded'); + assert.equal(env.store.hasRecordForId('person', '1'), true, 'The person is in the store'); + assert.equal(env.store._internalModelsFor('person').has('1'), true, 'The person internalModel is loaded'); + assert.equal(env.store.hasRecordForId('boat', '1'), true, 'The boat is in the store'); + assert.equal(env.store._internalModelsFor('boat').has('1'), true, 'The boat internalModel is loaded'); let boats = run(() => adam.get('boats')); @@ -228,16 +229,18 @@ test("an async has many relationship to an unloaded record can restore that reco run(() => boaty.unloadRecord()); assert.equal(boats.get('length'), 1, 'after unloading boats.length is correct'); - assert.equal(env.store.hasRecordForId('boat', 1), false, 'The boat is unloaded'); - assert.equal(env.store._internalModelsFor('boat').has(1), true, 'The boat internalModel is retained'); + assert.equal(env.store.hasRecordForId('boat', '1'), false, 'The boat is unloaded'); + assert.equal(env.store._internalModelsFor('boat').has('1'), true, 'The boat internalModel is retained'); - let rematerializedBoaty = run(() => adam.get('boats')).objectAt(0); + boats = run(() => adam.get('boats')); + let rematerializedBoaty = boats.objectAt(1); + assert.ok(!!rematerializedBoaty, 'We have a boat!'); assert.equal(adam.get('boats.length'), 2, 'boats.length correct after rematerialization'); - assert.equal(rematerializedBoaty.get('id'), '1'); - assert.equal(rematerializedBoaty.get('name'), 'Boaty McBoatface'); + assert.equal(rematerializedBoaty.get('id'), '1', 'Rematerialized boat has the right id'); + assert.equal(rematerializedBoaty.get('name'), 'Boaty McBoatface', 'Rematerialized boat has the right name'); assert.notEqual(rematerializedBoaty, boaty, 'the boat is rematerialized, not recycled'); - assert.equal(env.store.hasRecordForId('boat', 1), true, 'The boat is loaded'); - assert.equal(env.store._internalModelsFor('boat').has(1), true, 'The boat internalModel is retained'); + assert.equal(env.store.hasRecordForId('boat', '1'), true, 'The boat is loaded'); + assert.equal(env.store._internalModelsFor('boat').has('1'), true, 'The boat internalModel is retained'); }); diff --git a/tests/integration/relationships/belongs-to-test.js b/tests/integration/relationships/belongs-to-test.js index 6b954f18463..60e58df49d1 100644 --- a/tests/integration/relationships/belongs-to-test.js +++ b/tests/integration/relationships/belongs-to-test.js @@ -43,12 +43,12 @@ module("integration/relationship/belongs_to Belongs-To Relationships", { Book = DS.Model.extend({ name: attr('string'), author: belongsTo('author', { async: false }), - chapters: hasMany('chapters', { async: false }) + chapters: hasMany('chapters', { async: false, inverse: 'book' }) }); Chapter = DS.Model.extend({ title: attr('string'), - book: belongsTo('book', { async: false }) + book: belongsTo('book', { async: false, inverse: 'chapters' }) }); Author = DS.Model.extend({ @@ -804,7 +804,7 @@ testInDebug("Passing a model as type to belongsTo should not work", function(ass }, /The first argument to DS.belongsTo must be a string/); }); -test("belongsTo hasData async loaded", function(assert) { +test("belongsTo hasAnyRelationshipData async loaded", function(assert) { assert.expect(1); Book.reopen({ @@ -827,12 +827,12 @@ test("belongsTo hasData async loaded", function(assert) { return run(() => { return store.findRecord('book', 1).then(book => { let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); }); -test("belongsTo hasData sync loaded", function(assert) { +test("belongsTo hasAnyRelationshipData sync loaded", function(assert) { assert.expect(1); env.adapter.findRecord = function(store, type, id, snapshot) { @@ -851,12 +851,12 @@ test("belongsTo hasData sync loaded", function(assert) { return run(() => { return store.findRecord('book', 1).then(book => { let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); }); -test("belongsTo hasData async not loaded", function(assert) { +test("belongsTo hasAnyRelationshipData async not loaded", function(assert) { assert.expect(1); Book.reopen({ @@ -879,12 +879,12 @@ test("belongsTo hasData async not loaded", function(assert) { return run(() => { return store.findRecord('book', 1).then(book => { let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); }); }); }); -test("belongsTo hasData sync not loaded", function(assert) { +test("belongsTo hasAnyRelationshipData sync not loaded", function(assert) { assert.expect(1); env.adapter.findRecord = function(store, type, id, snapshot) { @@ -900,12 +900,12 @@ test("belongsTo hasData sync not loaded", function(assert) { return run(() => { return store.findRecord('book', 1).then(book => { let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); }); }); }); -test("belongsTo hasData NOT created", function(assert) { +test("belongsTo hasAnyRelationshipData NOT created", function(assert) { assert.expect(2); Book.reopen({ @@ -917,7 +917,7 @@ test("belongsTo hasData NOT created", function(assert) { let book = store.createRecord('book', { name: 'The Greatest Book' }); let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); book = store.createRecord('book', { name: 'The Greatest Book', @@ -926,11 +926,11 @@ test("belongsTo hasData NOT created", function(assert) { relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); -test("belongsTo hasData sync created", function(assert) { +test("belongsTo hasAnyRelationshipData sync created", function(assert) { assert.expect(2); run(() => { @@ -940,7 +940,7 @@ test("belongsTo hasData sync created", function(assert) { }); let relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); book = store.createRecord('book', { name: 'The Greatest Book', @@ -948,7 +948,7 @@ test("belongsTo hasData sync created", function(assert) { }); relationship = book._internalModel._relationships.get('author'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); @@ -998,7 +998,7 @@ test("Model's belongsTo relationship should be created during 'get' method", fun }); }); -test("Related link should be fetched when no local data is present", function(assert) { +test("Related link should be fetched when no relationship data is present", function(assert) { assert.expect(3); Book.reopen({ @@ -1010,7 +1010,7 @@ test("Related link should be fetched when no local data is present", function(as assert.ok(true, "The adapter's findBelongsTo method should be called"); return resolve({ data: { - id: 1, + id: '1', type: 'author', attributes: { name: 'This is author' } } @@ -1038,18 +1038,15 @@ test("Related link should be fetched when no local data is present", function(as }); }); -test("Local data should take precedence over related link", function(assert) { - assert.expect(1); +test("Related link should take precedence over relationship data if no local record data is available", function(assert) { + assert.expect(2); Book.reopen({ author: DS.belongsTo('author', { async: true }) }); env.adapter.findBelongsTo = function(store, snapshot, url, relationship) { - assert.ok(false, "The adapter's findBelongsTo method should not be called"); - }; - - env.adapter.findRecord = function(store, type, id, snapshot) { + assert.ok(true, "The adapter's findBelongsTo method should be called"); return resolve({ data: { id: 1, @@ -1059,6 +1056,10 @@ test("Local data should take precedence over related link", function(assert) { }); }; + env.adapter.findRecord = function() { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + return run(() => { let book = env.store.push({ data: { @@ -1081,6 +1082,51 @@ test("Local data should take precedence over related link", function(assert) { }); }); +test("Relationship data should take precedence over related link when local record data is available", function(assert) { + assert.expect(1); + + Book.reopen({ + author: DS.belongsTo('author', { async: true }) + }); + + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; + env.adapter.findBelongsTo = function(store, snapshot, url, relationship) { + assert.ok(false, "The adapter's findBelongsTo method should not be called"); + }; + + env.adapter.findRecord = function(store, type, id, snapshot) { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + + return run(() => { + let book = env.store.push({ + data: { + type: 'book', + id: '1', + relationships: { + author: { + links: { + related: 'author' + }, + data: { type: 'author', id: '1' } + } + } + }, + included: [ + { + id: '1', + type: 'author', + attributes: { name: 'This is author' } + } + ] + }); + + return book.get('author').then(author => { + assert.equal(author.get('name'), 'This is author', 'author name is correct'); + }); + }); +}); + test("New related link should take precedence over local data", function(assert) { assert.expect(3); @@ -1140,7 +1186,7 @@ test("New related link should take precedence over local data", function(assert) }); }); -test("Updated related link should take precedence over local data", function(assert) { +test("Updated related link should take precedence over relationship data and local record data", function(assert) { assert.expect(4); Book.reopen({ @@ -1152,9 +1198,11 @@ test("Updated related link should take precedence over local data", function(ass assert.ok(true, "The adapter's findBelongsTo method should be called"); return resolve({ data: { - id: 1, + id: '1', type: 'author', - attributes: { name: 'This is updated author' } + attributes: { + name: 'This is updated author' + } } }); }; @@ -1177,18 +1225,21 @@ test("Updated related link should take precedence over local data", function(ass } } }, - included: [{ - type: 'author', - id: '1', - attributes: { - name: 'This is author' + included: [ + { + type: 'author', + id: '1', + attributes: { + name: 'This is author' + } } - }] + ] }); return book.get('author').then((author) => { assert.equal(author.get('name'), 'This is author', 'author name is correct'); }).then(() => { + env.store.push({ data: { type: 'book', @@ -1329,9 +1380,9 @@ test("A belongsTo relationship can be reloaded using the reference if it was fet }); }); -test("A sync belongsTo relationship can be reloaded using a reference if it was fetched via id", function(assert) { +test("A synchronous belongsTo relationship can be reloaded using a reference if it was fetched via id", function(assert) { Chapter.reopen({ - book: DS.belongsTo() + book: DS.belongsTo({ async: false }) }); let chapter; @@ -1339,10 +1390,10 @@ test("A sync belongsTo relationship can be reloaded using a reference if it was chapter = env.store.push({ data: { type: 'chapter', - id: 1, + id: '1', relationships: { book: { - data: { type: 'book', id: 1 } + data: { type: 'book', id: '1' } } } } @@ -1350,7 +1401,7 @@ test("A sync belongsTo relationship can be reloaded using a reference if it was env.store.push({ data: { type: 'book', - id: 1, + id: '1', attributes: { name: "book title" } @@ -1361,7 +1412,7 @@ test("A sync belongsTo relationship can be reloaded using a reference if it was env.adapter.findRecord = function() { return resolve({ data: { - id: 1, + id: '1', type: 'book', attributes: { name: 'updated book title' } } diff --git a/tests/integration/relationships/has-many-test.js b/tests/integration/relationships/has-many-test.js index fe63d8490f5..f73db1f5c7d 100644 --- a/tests/integration/relationships/has-many-test.js +++ b/tests/integration/relationships/has-many-test.js @@ -2648,7 +2648,7 @@ test("adding and removing records from hasMany relationship #2666", function(ass }); }); -test("hasMany hasData async loaded", function(assert) { +test("hasMany hasAnyRelationshipData async loaded", function(assert) { assert.expect(1); Chapter.reopen({ @@ -2673,12 +2673,12 @@ test("hasMany hasData async loaded", function(assert) { return run(() => { return store.findRecord('chapter', 1).then(chapter => { let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); }); -test("hasMany hasData sync loaded", function(assert) { +test("hasMany hasAnyRelationshipData sync loaded", function(assert) { assert.expect(1); env.adapter.findRecord = function(store, type, id, snapshot) { @@ -2699,12 +2699,12 @@ test("hasMany hasData sync loaded", function(assert) { return run(() => { return store.findRecord('chapter', 1).then(chapter => { let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); }); }); -test("hasMany hasData async not loaded", function(assert) { +test("hasMany hasAnyRelationshipData async not loaded", function(assert) { assert.expect(1); Chapter.reopen({ @@ -2729,12 +2729,12 @@ test("hasMany hasData async not loaded", function(assert) { return run(() => { return store.findRecord('chapter', 1).then(chapter => { let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); }); }); }); -test("hasMany hasData sync not loaded", function(assert) { +test("hasMany hasAnyRelationshipData sync not loaded", function(assert) { assert.expect(1); env.adapter.findRecord = function(store, type, id, snapshot) { @@ -2750,12 +2750,12 @@ test("hasMany hasData sync not loaded", function(assert) { return run(() => { return store.findRecord('chapter', 1).then(chapter => { let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); }); }); }); -test("hasMany hasData async created", function(assert) { +test("hasMany hasAnyRelationshipData async created", function(assert) { assert.expect(2); Chapter.reopen({ @@ -2766,7 +2766,7 @@ test("hasMany hasData async created", function(assert) { let page = store.createRecord('page'); let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); chapter = store.createRecord('chapter', { title: 'The Story Begins', @@ -2774,16 +2774,16 @@ test("hasMany hasData async created", function(assert) { }); relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); -test("hasMany hasData sync created", function(assert) { +test("hasMany hasAnyRelationshipData sync created", function(assert) { assert.expect(2); let chapter = store.createRecord('chapter', { title: 'The Story Begins' }); let relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, false, 'relationship does not have data'); + assert.equal(relationship.hasAnyRelationshipData, false, 'relationship does not have data'); chapter = store.createRecord('chapter', { title: 'The Story Begins', @@ -2791,7 +2791,7 @@ test("hasMany hasData sync created", function(assert) { }); relationship = chapter._internalModel._relationships.get('pages'); - assert.equal(relationship.hasData, true, 'relationship has data'); + assert.equal(relationship.hasAnyRelationshipData, true, 'relationship has data'); }); test("Model's hasMany relationship should not be created during model creation", function(assert) { @@ -2973,19 +2973,37 @@ test("metadata should be reset between requests", function(assert) { }); }); -test("Related link should be fetched when no local data is present", function(assert) { +test("Related link should be fetched when no relationship data is present", function(assert) { assert.expect(3); Post.reopen({ - comments: DS.hasMany('comment', { async: true }) + comments: DS.hasMany('comment', { async: true, inverse: 'post' }) + }); + Comment.reopen({ + post: DS.belongsTo('post', { async: false, inverse: 'comments' }) }); + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; + env.adapter.findRecord = () => { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + env.adapter.findMany = () => { + assert.ok(false, "The adapter's findMany method should not be called"); + }; env.adapter.findHasMany = function(store, snapshot, url, relationship) { - assert.equal(url, 'comments', 'url is correct'); + assert.equal(url, 'get-comments', 'url is correct'); assert.ok(true, "The adapter's findHasMany method should be called"); - return resolve({ data: [ - { id: 1, type: 'comment', attributes: { body: 'This is comment' } } - ]}); + return resolve({ + data: [ + { + id: '1', + type: 'comment', + attributes: { + body: 'This is comment' + } + } + ] + }); }; return run(() => { @@ -2996,7 +3014,7 @@ test("Related link should be fetched when no local data is present", function(as relationships: { comments: { links: { - related: 'comments' + related: 'get-comments' } } } @@ -3009,19 +3027,82 @@ test("Related link should be fetched when no local data is present", function(as }); }); -test("Local data should take precedence over related link", function(assert) { - assert.expect(1); +test("Related link should take precedence over relationship data when local record data is missing", function(assert) { + assert.expect(3); Post.reopen({ - comments: DS.hasMany('comment', { async: true }) + comments: DS.hasMany('comment', { async: true, inverse: 'post' }) }); + Comment.reopen({ + post: DS.belongsTo('post', { async: false, inverse: 'comments' }) + }); + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; + env.adapter.findRecord = () => { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + env.adapter.findMany = () => { + assert.ok(false, "The adapter's findMany method should not be called"); + }; env.adapter.findHasMany = function(store, snapshot, url, relationship) { - assert.ok(false, "The adapter's findHasMany method should not be called"); + assert.equal(url, 'get-comments', 'url is correct'); + assert.ok(true, "The adapter's findHasMany method should be called"); + return resolve({ + data: [ + { + id: '1', + type: 'comment', + attributes: { + body: 'This is comment' + } + } + ] + }); }; - env.adapter.findRecord = function(store, type, id, snapshot) { - return resolve({ data: { id: 1, type: 'comment', attributes: { body: 'This is comment' } } }); + return run(() => { + let post = env.store.push({ + data: { + type: 'post', + id: '1', + relationships: { + comments: { + links: { + related: 'get-comments' + }, + data: [ + { type: 'comment', id: '1' } + ] + } + } + } + }); + + return post.get('comments').then(comments => { + assert.equal(comments.get('firstObject.body'), 'This is comment', 'comment body is correct'); + }); + }); +}); + +test("Local relationship data should take precedence over related link when local record data is available", function(assert) { + assert.expect(1); + + Post.reopen({ + comments: DS.hasMany('comment', { async: true, inverse: 'post' }) + }); + Comment.reopen({ + post: DS.belongsTo('post', { async: false, inverse: 'comments' }) + }); + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; + env.adapter.findRecord = () => { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + env.adapter.findMany = () => { + assert.ok(false, "The adapter's findMany method should not be called"); + }; + + env.adapter.findHasMany = function(store, snapshot, url, relationship) { + assert.ok(false, "The adapter's findHasMany method should not be called"); }; return run(() => { @@ -3032,14 +3113,23 @@ test("Local data should take precedence over related link", function(assert) { relationships: { comments: { links: { - related: 'comments' + related: 'get-comments' }, data: [ { type: 'comment', id: '1' } ] } } - } + }, + included: [ + { + id: '1', + type: 'comment', + attributes: { + body: 'This is comment' + } + } + ] }); return post.get('comments').then(comments => { @@ -3048,7 +3138,78 @@ test("Local data should take precedence over related link", function(assert) { }); }); -test("Updated related link should take precedence over local data", function(assert) { +test("Related link should take precedence over local record data when relationship data is not initially available", function(assert) { + assert.expect(3); + + Post.reopen({ + comments: DS.hasMany('comment', { async: true, inverse: 'post' }) + }); + Comment.reopen({ + post: DS.belongsTo('post', { async: false, inverse: 'comments' }) + }); + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; + env.adapter.findRecord = () => { + assert.ok(false, "The adapter's findRecord method should not be called"); + }; + env.adapter.findMany = () => { + assert.ok(false, "The adapter's findMany method should not be called"); + }; + + env.adapter.findHasMany = function(store, snapshot, url, relationship) { + assert.equal(url, 'get-comments', 'url is correct'); + assert.ok(true, "The adapter's findHasMany method should be called"); + return resolve({ + data: [ + { + id: '1', + type: 'comment', + attributes: { + body: 'This is comment fetched by link' + } + } + ] + }); + }; + + return run(() => { + let post = env.store.push({ + data: { + type: 'post', + id: '1', + relationships: { + comments: { + links: { + related: 'get-comments' + } + } + } + }, + included: [ + { + id: '1', + type: 'comment', + attributes: { + body: 'This is comment' + }, + relationships: { + post: { + data: { + type: 'post', + id: '1' + } + } + } + } + ] + }); + + return post.get('comments').then(comments => { + assert.equal(comments.get('firstObject.body'), 'This is comment fetched by link', 'comment body is correct'); + }); + }); +}); + +test("Updated related link should take precedence over relationship data and local record data", function(assert) { assert.expect(3); Post.reopen({ @@ -3059,7 +3220,7 @@ test("Updated related link should take precedence over local data", function(ass assert.equal(url, 'comments-updated-link', 'url is correct'); assert.ok(true, "The adapter's findHasMany method should be called"); return resolve({ data: [ - { id: 1, type: 'comment', attributes: { body: 'This is comment' } } + { id: 1, type: 'comment', attributes: { body: 'This is updated comment' } } ]}); }; @@ -3100,7 +3261,7 @@ test("Updated related link should take precedence over local data", function(ass }); return post.get('comments').then(comments => { - assert.equal(comments.get('firstObject.body'), 'This is comment', 'comment body is correct'); + assert.equal(comments.get('firstObject.body'), 'This is updated comment', 'comment body is correct'); }); }); }); @@ -3365,3 +3526,79 @@ test("hasMany relationship with links doesn't trigger extra change notifications assert.equal(count, 0); }); + +test("A hasMany relationship with a link will trigger the link request even if a inverse related object is pushed to the store", function(assert) { + Post.reopen({ + comments: DS.hasMany('comment', { async: true }) + }); + + Comment.reopen({ + message: DS.belongsTo('post', { async: true}) + }); + + const postID = '1'; + + run(function() { + // load a record with a link hasMany relationship + env.store.push({ + data: { + type: 'post', + id: postID, + relationships: { + comments: { + links: { + related: '/posts/1/comments' + } + } + } + } + }); + + // if a related comment is pushed into the store, + // the post.comments.link will not be requested + // + // If this comment is not inserted into the store, everything works properly + env.store.push({ + data: { + type: 'comment', + id: '1', + attributes: { body: "First" }, + relationships: { + message: { + data: { type: 'post', id: postID } + } + } + } + }); + + env.adapter.findRecord = function(store, type, id, snapshot) { + throw new Error(`findRecord for ${type} should not be called`); + }; + + let hasManyCounter = 0; + env.adapter.findHasMany = function(store, snapshot, link, relationship) { + assert.equal(relationship.type, 'comment', "findHasMany relationship type was Comment"); + assert.equal(relationship.key, 'comments', "findHasMany relationship key was comments"); + assert.equal(link, "/posts/1/comments", "findHasMany link was /posts/1/comments"); + hasManyCounter++; + + return resolve({ data: [ + { id: 1, type: 'comment', attributes: { body: "First" } }, + { id: 2, type: 'comment', attributes: { body: "Second" } } + ]}); + }; + + const post = env.store.peekRecord('post', postID); + post.get('comments').then(function(comments) { + assert.equal(comments.get('isLoaded'), true, "comments are loaded"); + assert.equal(hasManyCounter, 1, "link was requested"); + assert.equal(comments.get('length'), 2, "comments have 2 length"); + + post.hasMany('comments').reload().then(function(comments) { + assert.equal(comments.get('isLoaded'), true, "comments are loaded"); + assert.equal(hasManyCounter, 2, "link was requested"); + assert.equal(comments.get('length'), 2, "comments have 2 length"); + }); + }); + }); +}); diff --git a/tests/integration/relationships/json-api-links-test.js b/tests/integration/relationships/json-api-links-test.js new file mode 100644 index 00000000000..7c094e7b4c3 --- /dev/null +++ b/tests/integration/relationships/json-api-links-test.js @@ -0,0 +1,1932 @@ +import { run } from '@ember/runloop'; +import { get } from '@ember/object'; +import Ember from 'ember'; +import { resolve } from 'rsvp'; +import setupStore from 'dummy/tests/helpers/store'; +import { + reset as resetModelFactoryInjection +} from 'dummy/tests/helpers/model-factory-injection'; +import { module, test } from 'qunit'; +import DS from 'ember-data'; +import JSONAPIAdapter from "ember-data/adapters/json-api"; + +const { copy } = Ember; +const { Model, attr, hasMany, belongsTo } = DS; + +let env, User, Organisation; + +module("integration/relationship/json-api-links | Relationship state updates", { + beforeEach() {}, + + afterEach() { + resetModelFactoryInjection(); + run(env.container, 'destroy'); + } +}); + +test("Loading link with inverse:null on other model caches the two ends separately", function (assert) { + User = DS.Model.extend({ + organisation: belongsTo('organisation', { inverse: null }) + }); + + Organisation = DS.Model.extend({ + adminUsers: hasMany('user', { inverse: null }) + }); + + env = setupStore({ + user: User, + organisation: Organisation + }); + + env.registry.optionsForType('serializer', { singleton: false }); + env.registry.optionsForType('adapter', { singleton: false }); + + const store = env.store; + + User = store.modelFor('user'); + Organisation = store.modelFor('organisation'); + + env.registry.register('adapter:user', DS.JSONAPISerializer.extend({ + findRecord(store, type, id) { + return resolve({ + data: { + id, + type: 'user', + relationships: { + organisation: { + data: { id: 1, type: 'organisation' } + } + } + } + }); + } + })); + + env.registry.register('adapter:organisation', DS.JSONAPISerializer.extend({ + findRecord(store, type, id) { + return resolve({ + data: { + type: 'organisation', + id, + relationships: { + 'admin-users': { + links: { + related: '/org-admins' + } + } + } + } + }); + } + })); + + return run(() => { + return store.findRecord('user', 1) + .then(user1 => { + assert.ok(user1, 'user should be populated'); + + return store.findRecord('organisation', 2) + .then(org2FromFind => { + assert.equal(user1.belongsTo('organisation').remoteType(), 'id', `user's belongsTo is based on id`); + assert.equal(user1.belongsTo('organisation').id(), 1, `user's belongsTo has its id populated`); + + return user1.get('organisation') + .then(orgFromUser => { + assert.equal(user1.belongsTo('organisation').belongsToRelationship.relationshipIsStale, false, 'user should have loaded its belongsTo relationship'); + + assert.ok(org2FromFind, 'organisation we found should be populated'); + assert.ok(orgFromUser, 'user\'s organisation should be populated'); + }) + }) + }) + }); +}); + +test("Pushing child record should not mark parent:children as loaded", function (assert) { + let Child = DS.Model.extend({ + parent: belongsTo('parent', { inverse: 'children' }) + }); + + let Parent = DS.Model.extend({ + children: hasMany('child', { inverse: 'parent' }) + }); + + env = setupStore({ + parent: Parent, + child: Child + }); + + env.registry.optionsForType('serializer', { singleton: false }); + env.registry.optionsForType('adapter', { singleton: false }); + + const store = env.store; + + Parent = store.modelFor('parent'); + Child = store.modelFor('child'); + + return run(() => { + const parent = store.push({ + data: { + id: 'p1', + type: 'parent', + relationships: { + children: { + links: { + related: '/parent/1/children' + } + } + } + } + }); + + store.push({ + data: { + id: 'c1', + type: 'child', + relationships: { + parent: { + data: { + id: 'p1', + type: 'parent' + } + } + } + } + }); + + assert.equal(parent.hasMany('children').hasManyRelationship.relationshipIsStale, true, 'parent should think that children still needs to be loaded'); + }); +}); + +test("pushing has-many payloads with data (no links), then more data (no links) works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany() { + assert.ok(false, 'We dont fetch a link when we havent given a link'); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findRecord'); + }, + findRecord(_, __, id) { + assert.ok(id !== '1', `adapter findRecord called for all IDs except "1", called for "${id}"`); + return resolve({ + data: { + type: 'pet', + id, + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + }); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push data, no links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + } + } + } + })); + + // push links, no data + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '2' }, + { type: 'pet', id: '3' } + ] + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + run(() => get(Chris, 'pets')); +}); + +test("pushing has-many payloads with data (no links), then links (no data) works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany(_, __, link) { + assert.ok(link === './user/1/pets', 'We fetched via the correct link'); + return resolve({ + data: [ + { + type: 'pet', + id: '1', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + }, + { + type: 'pet', + id: '2', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + ] + }); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findHasMany with a link'); + }, + findRecord() { + assert.ok(false, 'adapter findRecord called instead of using findHasMany with a link'); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push data, no links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + } + } + } + })); + + // push links, no data + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + links: { + related: './user/1/pets' + } + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + run(() => get(Chris, 'pets')); +}); + +test("pushing has-many payloads with links (no data), then data (no links) works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany(_, __, link) { + assert.ok(link === './user/1/pets', 'We fetched via the correct link'); + return resolve({ + data: [ + { + type: 'pet', + id: '1', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + }, + { + type: 'pet', + id: '2', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + ] + }); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findHasMany with a link'); + }, + findRecord() { + assert.ok(false, 'adapter findRecord called instead of using findHasMany with a link'); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push links, no data + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + links: { + related: './user/1/pets' + } + } + } + } + })); + + // push data, no links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + + // we expect to still use the link info + run(() => get(Chris, 'pets')); +}); + +test("pushing has-many payloads with links, then links again works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany(_, __, link) { + assert.ok(link === './user/1/pets', 'We fetched via the correct link'); + return resolve({ + data: [ + { + type: 'pet', + id: '1', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + }, + { + type: 'pet', + id: '2', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + ] + }); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findHasMany with a link'); + }, + findRecord() { + assert.ok(false, 'adapter findRecord called instead of using findHasMany with a link'); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push links, no data + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + links: { + related: './user/1/not-pets' + } + } + } + } + })); + + // push data, no links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + links: { + related: './user/1/pets' + } + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + + // we expect to use the link info from the second push + run(() => get(Chris, 'pets')); +}); + +test("pushing has-many payloads with links and data works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany(_, __, link) { + assert.ok(link === './user/1/pets', 'We fetched via the correct link'); + return resolve({ + data: [ + { + type: 'pet', + id: '1', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + }, + { + type: 'pet', + id: '2', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + ] + }); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findHasMany with a link'); + }, + findRecord() { + assert.ok(false, 'adapter findRecord called instead of using findHasMany with a link'); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push data and links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ], + links: { + related: './user/1/pets' + } + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + run(() => get(Chris, 'pets')); +}); + +test("pushing has-many payloads with links, then one with links and data works as expected", function(assert) { + const User = Model.extend({ + pets: hasMany('pet', { async: true, inverse: 'owner' }) + }); + const Pet = Model.extend({ + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend({ + findHasMany(_, __, link) { + assert.ok(link === './user/1/pets', 'We fetched via the correct link'); + return resolve({ + data: [ + { + type: 'pet', + id: '1', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + }, + { + type: 'pet', + id: '2', + relationships: { + owner: { + data: { type: 'user', id: '1' } + } + } + } + ] + }); + }, + findMany() { + assert.ok(false, 'adapter findMany called instead of using findHasMany with a link'); + }, + findRecord() { + assert.ok(false, 'adapter findRecord called instead of using findHasMany with a link'); + } + }); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet + }); + + let { store } = env; + + // push data, no links + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + } + } + } + })); + + // push links and data + run(() => store.push({ + data: { + type: 'user', + id: '1', + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' }, + { type: 'pet', id: '2' }, + { type: 'pet', id: '3' } + ], + links: { + related: './user/1/pets' + } + } + } + } + })); + + let Chris = run(() => store.peekRecord('user', '1')); + run(() => get(Chris, 'pets')); +}); + +module("integration/relationship/json-api-links | Relationship fetching", { + beforeEach() { + const User = Model.extend({ + name: attr(), + pets: hasMany('pet', { async: true, inverse: 'owner' }), + home: belongsTo('home', { async: true, inverse: 'owner' }) + }); + const Home = Model.extend({ + address: attr(), + owner: belongsTo('user', { async: false, inverse: 'home' }) + }); + const Pet = Model.extend({ + name: attr(), + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + const Adapter = JSONAPIAdapter.extend(); + + env = setupStore({ + adapter: Adapter, + user: User, + pet: Pet, + home: Home + }); + }, + + afterEach() { + resetModelFactoryInjection(); + run(env.container, 'destroy'); + env = null; + } +}); + +/* +Tests: + +Fetches Link +- get/reload hasMany with a link (no data) +- get/reload hasMany with a link and data (not available in store) +- get/reload hasMany with a link and empty data (`data: []`) + +Uses Link for Reload +- get/reload hasMany with a link and data (available in store) + +Does Not Use Link (as there is none) +- get/reload hasMany with data, no links +- get/reload hasMany with no data, no links +*/ + +/* + Used for situations when even initially we should fetch via link + */ +function shouldFetchLinkTests(description, payloads) { + test(`get+reload hasMany with ${description}`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = (_, __, link) => { + assert.ok( + link === payloads.user.data.relationships.pets.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.pets, true)); + }; + + // setup user + let user = run(() => store.push(copy(payloads.user, true))); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.reload()); + }); + test(`get+unload+get hasMany with ${description}`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + let petRelationshipData = payloads.user.data.relationships.pets.data; + let petRelDataWasEmpty = petRelationshipData && petRelationshipData.length === 0; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = (_, __, link) => { + if (petRelDataWasEmpty) { + assert.ok( + link === payloads.user.data.relationships.pets.links.related, + 'We fetched this link even though we really should not have' + ); + } else { + assert.ok( + link === payloads.user.data.relationships.pets.links.related, + 'We fetched the appropriate link' + ); + } + return resolve(copy(payloads.pets, true)); + }; + + // setup user + let user = run(() => store.push(copy(payloads.user, true))); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + if (!petRelDataWasEmpty) { + run(() => pets.objectAt(0).unloadRecord()); + run(() => user.get('pets')); + } else { + assert.ok(true, `We cant dirty a relationship we have no knowledge of`); + } + }); + test(`get+reload belongsTo with ${description}`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + let homeRelationshipData = payloads.user.data.relationships.home.data; + let homeRelWasEmpty = homeRelationshipData === null; + let isInitialFetch = true; + let didFetchInitially = false; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findBelongsTo = (_, __, link) => { + if (isInitialFetch && homeRelWasEmpty) { + assert.ok(false, 'We should not fetch a relationship we believe is empty'); + didFetchInitially = true; + } else { + assert.ok( + link === payloads.user.data.relationships.home.links.related, + 'We fetched the appropriate link' + ); + } + return resolve(copy(payloads.home, true)); + }; + + // setup user + let user = run(() => store.push(copy(payloads.user, true))); + let home = run(() => user.get('home')); + + if (homeRelWasEmpty) { + assert.ok(!didFetchInitially, 'We did not fetch'); + } + + assert.ok(!!home, 'We found our home'); + isInitialFetch = false; + + run(() => home.reload()); + }); + test(`get+unload+get belongsTo with ${description}`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + let homeRelationshipData = payloads.user.data.relationships.home.data; + let homeRelWasEmpty = homeRelationshipData === null; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findBelongsTo = (_, __, link) => { + assert.ok( + !homeRelWasEmpty && + link === payloads.user.data.relationships.home.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.home, true)); + }; + + // setup user + let user = run(() => store.push(copy(payloads.user, true))); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + if (!homeRelWasEmpty) { + run(() => home.then(h => h.unloadRecord())); + run(() => user.get('home')); + } else { + assert.ok(true, `We cant dirty a relationship we have no knowledge of`); + assert.ok(true, `Nor should we have fetched it.`); + } + }); +} + +shouldFetchLinkTests('a link (no data)', { + user: { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + } + }, + home: { + links: { + related: './runspired/address' + } + } + } + } + }, + pets: { + data: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + }, + home: { + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + } +}); + +shouldFetchLinkTests('a link and data (not available in the store)', { + user: { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + }, + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + links: { + related: './runspired/address' + }, + data: { type: 'home', id: '1' } + } + } + } + }, + pets: { + data: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + }, + links: { + related: './user/1' + } + } + } + } + ] + }, + home: { + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + }, + links: { + related: './user/1' + } + } + } + } + } +}); + +/* + Used for situations when initially we have data, but reload/missing data + situations should be done via link + */ +function shouldReloadWithLinkTests(description, payloads) { + test(`get+reload hasMany with ${description}`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = (_, __, link) => { + assert.ok( + link === payloads.user.data.relationships.pets.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.pets, true)); + }; + + // setup user and pets + let user = run(() => store.push(copy(payloads.user, true))); + run(() => store.push(copy(payloads.pets, true))); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.reload()); + }); + test(`get+unload+get hasMany with ${description}`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = (_, __, link) => { + assert.ok( + link === payloads.user.data.relationships.pets.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.pets, true)); + }; + + // setup user and pets + let user = run(() => store.push(copy(payloads.user, true))); + run(() => store.push(copy(payloads.pets, true))); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.objectAt(0).unloadRecord()); + run(() => user.get('pets')); + }); + test(`get+reload belongsTo with ${description}`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findBelongsTo = (_, __, link) => { + assert.ok( + link === payloads.user.data.relationships.home.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.home, true)); + }; + + // setup user and home + let user = run(() => store.push(copy(payloads.user, true))); + run(() => store.push(copy(payloads.home, true))); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + run(() => home.reload()); + }); + test(`get+unload+get belongsTo with ${description}`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findBelongsTo = (_, __, link) => { + assert.ok( + link === payloads.user.data.relationships.home.links.related, + 'We fetched the appropriate link' + ); + return resolve(copy(payloads.home, true)); + }; + + // setup user + let user = run(() => store.push(copy(payloads.user, true))); + run(() => store.push(copy(payloads.home, true))); + let home; + run(() => user.get('home').then(h => home = h)); + + assert.ok(!!home, 'We found our home'); + + run(() => home.unloadRecord()); + run(() => user.get('home')); + }); +} + +shouldReloadWithLinkTests('a link and data (available in the store)', { + user: { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + }, + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + links: { + related: './runspired/address' + }, + data: { type: 'home', id: '1' } + } + } + } + }, + pets: { + data: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + }, + home: { + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + } +}); + +shouldReloadWithLinkTests('a link and empty data (`data: []` or `data: null`), true inverse loaded', { + user: { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + }, + data: [] + }, + home: { + links: { + related: './runspired/address' + }, + data: null + } + } + } + }, + pets: { + data: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + }, + links: { + related: './user/1' + } + } + } + } + ] + }, + home: { + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + }, + links: { + related: './user/1' + } + } + } + } + } +}); + +shouldReloadWithLinkTests('a link and empty data (`data: []` or `data: null`), true inverse unloaded', { + user: { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + }, + data: [] + }, + home: { + links: { + related: './runspired/address' + }, + data: null + } + } + } + }, + pets: { + data: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + }, + home: { + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + } +}); + +/* + Ad Hoc Situations when we don't have a link + */ + +// data, no links +test(`get+reload hasMany with data, no links`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + data: { type: 'home', id: '1' } + } + } + } + })); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.reload()); +}); +test(`get+unload+get hasMany with data, no links`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + data: { type: 'home', id: '1' } + } + } + } + })); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.objectAt(0).unloadRecord()); + run(() => user.get('pets')); +}); +test(`get+reload belongsTo with data, no links`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, CA' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + + // setup user + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + data: { type: 'home', id: '1' } + } + } + } + })); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + run(() => home.reload()); +}); +test(`get+unload+get belongsTo with data, no links`, function(assert) { + assert.expect(3); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, Ca' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + + // setup user + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + data: [ + { type: 'pet', id: '1' } + ] + }, + home: { + data: { type: 'home', id: '1' } + } + } + } + })); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + run(() => home.then(h => h.unloadRecord())); + run(() => user.get('home')); +}); + +// missing data setup from the other side, no links +test(`get+reload hasMany with missing data setup from the other side, no links`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user and pet + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: {} + }, + included: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + })); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.reload()); +}); +test(`get+unload+get hasMany with missing data setup from the other side, no links`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user and pet + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: {} + }, + included: [ + { + type: 'pet', + id: '1', + attributes: { + name: 'Shen' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + })); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.objectAt(0).unloadRecord()); + run(() => user.get('pets')); +}); +test(`get+reload belongsTo with missing data setup from the other side, no links`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, CA' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user and home + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: {} + }, + included: [ + { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, CA' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + })); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + run(() => home.reload()); +}); +test(`get+unload+get belongsTo with missing data setup from the other side, no links`, function(assert) { + assert.expect(2); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(true, 'We should call findRecord'); + return resolve({ + data: { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, CA' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + }); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user and home + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: {} + }, + included: [ + { + type: 'home', + id: '1', + attributes: { + address: 'Oakland, CA' + }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + })); + let home = run(() => user.get('home')); + + assert.ok(!!home, 'We found our home'); + + run(() => home.then(h => h.unloadRecord())); + run(() => user.get('home')); +}); + +// empty data, no links +test(`get+reload hasMany with empty data, no links`, function(assert) { + assert.expect(1); + let { store, adapter } = env; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = () => { + assert.ok(false, 'We should not call findHasMany'); + }; + + // setup user + let user = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + data: [] + }, + home: { + data: null + } + } + } + })); + let pets = run(() => user.get('pets')); + + assert.ok(!!pets, 'We found our pets'); + + run(() => pets.reload()); +}); + +/* + Ad hoc situations where we do have a link + */ +test('We should not fetch a hasMany relationship with links that we know is empty', function(assert) { + assert.expect(1); + let { store, adapter } = env; + + let user1Payload = { + data: { + type: 'user', + id: '1', + attributes: { + name: '@runspired' + }, + relationships: { + pets: { + links: { + related: './runspired/pets' + }, + data: [] // we are explicitly told this is empty + } + } + } + }; + let user2Payload = { + data: { + type: 'user', + id: '2', + attributes: { + name: '@hjdivad' + }, + relationships: { + pets: { + links: { + related: './hjdivad/pets' + } + // we have no data, so we do not know that this is empty + } + } + } + }; + let requestedUser = null; + let failureDescription = ''; + + adapter.shouldBackgroundReloadRecord = () => false; + adapter.findRecord = () => { + assert.ok(false, 'We should not call findRecord'); + }; + adapter.findMany = () => { + assert.ok(false, 'We should not call findMany'); + }; + adapter.findHasMany = (_, __, link) => { + if (!requestedUser) { + assert.ok(false, failureDescription); + } else { + assert.ok( + link === requestedUser.data.relationships.pets.links.related, + 'We fetched the appropriate link' + ); + } + + return resolve({ + data: [] + }); + }; + + // setup users + let user1 = run(() => store.push(copy(user1Payload, true))); + let user2 = run(() => store.push(copy(user2Payload, true))); + + // should not fire a request + requestedUser = null; + failureDescription = 'We fetched the link for a known empty relationship'; + run(() => user1.get('pets')); + + // still should not fire a request + requestedUser = null; + failureDescription = 'We fetched the link (again) for a known empty relationship'; + run(() => user1.get('pets')); + + // should fire a request + requestedUser = user2Payload; + run(() => user2.get('pets')); + + // should not fire a request + requestedUser = null; + failureDescription = 'We fetched the link for a previously fetched and found to be empty relationship'; + run(() => user2.get('pets')); +}); diff --git a/tests/integration/relationships/many-to-many-test.js b/tests/integration/relationships/many-to-many-test.js index 420cb0fb5a1..5a614d08332 100644 --- a/tests/integration/relationships/many-to-many-test.js +++ b/tests/integration/relationships/many-to-many-test.js @@ -145,10 +145,9 @@ test("Fetching a hasMany where a record was removed reflects on the other hasMan }, relationships: { topics: { - data: [{ - id: '2', - type: 'topic' - }] + data: [ + { id: '2', type: 'topic' } + ] } } } diff --git a/tests/integration/relationships/nested-relationship-test.js b/tests/integration/relationships/nested-relationship-test.js index 9ef5d78c4a0..f6eceffee47 100644 --- a/tests/integration/relationships/nested-relationship-test.js +++ b/tests/integration/relationships/nested-relationship-test.js @@ -8,7 +8,7 @@ import DS from 'ember-data'; const { attr, hasMany, belongsTo } = DS; -let env, store, serializer, Elder, MiddleAger, Kid; +let env, store, Elder, MiddleAger, Kid; module('integration/relationships/nested_relationships_test - Nested relationships', { beforeEach() { @@ -36,7 +36,6 @@ module('integration/relationships/nested_relationships_test - Nested relationshi }); store = env.store; - serializer = env.serializer; }, afterEach() { @@ -49,36 +48,35 @@ module('integration/relationships/nested_relationships_test - Nested relationshi */ test('Sideloaded nested relationships load correctly', function(assert) { + env.adapter.shouldBackgroundReloadRecord = () => { return false; }; run(() => { - serializer.pushPayload(store, { - data: [ - { - id: '1', - type: 'kids', - links: { - self: '/kids/1' - }, - attributes: { - name: 'Kid 1' - }, - relationships: { - 'middle-ager': { - links: { - self: '/kids/1/relationships/middle-ager', - related: '/kids/1/middle-ager' - }, - data:{ - type: 'middle-agers', - id: '1' - } + store.push({ + data: { + id: '1', + type: 'kid', + links: { + self: '/kids/1' + }, + attributes: { + name: 'Kid 1' + }, + relationships: { + middleAger: { + links: { + self: '/kids/1/relationships/middle-ager', + related: '/kids/1/middle-ager' + }, + data:{ + type: 'middle-ager', + id: '1' } } } - ], + }, included: [ { id: '1', - type: 'middle-agers', + type: 'middle-ager', links: { self: '/middle-ager/1' }, @@ -92,7 +90,7 @@ test('Sideloaded nested relationships load correctly', function(assert) { related: '/middle-agers/1/elder' }, data: { - type: 'elders', + type: 'elder', id: '1' } }, @@ -100,14 +98,20 @@ test('Sideloaded nested relationships load correctly', function(assert) { links: { self: '/middle-agers/1/relationships/kids', related: '/middle-agers/1/kids' - } + }, + data: [ + { + type: 'kid', + id: '1' + } + ] } } }, { id: '1', - type: 'elders', + type: 'elder', links: { self: '/elders/1' }, @@ -115,7 +119,7 @@ test('Sideloaded nested relationships load correctly', function(assert) { name: 'Elder 1' }, relationships: { - 'middle-agers': { + middleAger: { links: { self: '/elders/1/relationships/middle-agers', related: '/elders/1/middle-agers' @@ -128,7 +132,7 @@ test('Sideloaded nested relationships load correctly', function(assert) { }); return run(() => { - let kid = store.peekRecord('kid', 1); + let kid = store.peekRecord('kid', '1'); return kid.get('middleAger').then(middleAger => { assert.ok(middleAger, 'MiddleAger relationship was set up correctly'); diff --git a/tests/integration/relationships/one-to-many-test.js b/tests/integration/relationships/one-to-many-test.js index ff2dcf8a1ed..9bb335fbf48 100644 --- a/tests/integration/relationships/one-to-many-test.js +++ b/tests/integration/relationships/one-to-many-test.js @@ -487,9 +487,13 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT }); test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsTo to null - sync", function(assert) { - var account; + let account1; + let account2; + let user; + run(function () { - store.push({ + // tell the store user:1 has account:1 + user = store.push({ data: { id: '1', type: 'user', @@ -498,15 +502,16 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT }, relationships: { accounts: { - data: [{ - id: '1', - type: 'account' - }] + data: [ + { id: '1', type: 'account' } + ] } } } }); - account = store.push({ + + // tell the store account:1 has user:1 + account1 = store.push({ data: { id: '1', type: 'account', @@ -515,15 +520,14 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT }, relationships: { user: { - data: { - id: '1', - type: 'user' - } + data: { id: '1', type: 'user' } } } } }); - store.push({ + + // tell the store account:2 has no user + account2 = store.push({ data: { id: '2', type: 'account', @@ -532,6 +536,8 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT } } }); + + // tell the store user:1 has account:2 and not account:1 store.push({ data: { id: '1', @@ -541,10 +547,9 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT }, relationships: { accounts: { - data: [{ - id: '2', - type: 'account' - }] + data: [ + { id: '2', type: 'account' } + ] } } } @@ -552,7 +557,8 @@ test("Fetching the hasMany that doesn't contain the belongsTo, sets the belongsT }); run(function() { - assert.equal(account.get('user'), null, 'User was removed correctly'); + assert.ok(account1.get('user') === null, 'User was removed correctly'); + assert.ok(account2.get('user') === user, 'User was added correctly'); }); }); diff --git a/tests/integration/snapshot-test.js b/tests/integration/snapshot-test.js index 6ae8d776c2b..3e8169a3440 100644 --- a/tests/integration/snapshot-test.js +++ b/tests/integration/snapshot-test.js @@ -75,7 +75,7 @@ test("snapshot.id, snapshot.type and snapshot.modelName returns correctly", func }); }); -// skipped because snapshot creation requires using `eachAttribute` +// TODO'd because snapshot creation requires using `eachAttribute` // which as an approach requires that we MUST load the class. // there may be strategies via which we can snapshot known attributes // only if no record exists yet, since we would then know for sure diff --git a/tests/unit/model/relationships/has-many-test.js b/tests/unit/model/relationships/has-many-test.js index 3cfb8a533ce..29bbbae7138 100644 --- a/tests/unit/model/relationships/has-many-test.js +++ b/tests/unit/model/relationships/has-many-test.js @@ -2005,6 +2005,78 @@ test('DS.ManyArray is lazy', function(assert) { }); }); +test('fetch hasMany loads full relationship after a parent and child have been loaded', function(assert) { + assert.expect(4); + + const Tag = DS.Model.extend({ + name: DS.attr('string'), + person: DS.belongsTo('person', { async: true, inverse: 'tags' }) + }); + + const Person = DS.Model.extend({ + name: DS.attr('string'), + tags: DS.hasMany('tag', { async: true, inverse: 'person' }) + }); + + let env = setupStore({ tag: Tag, person: Person }); + let { store } = env; + + env.adapter.findHasMany = function(store, snapshot, url, relationship) { + assert.equal(relationship.key, 'tags', 'relationship should be tags'); + + return { data: [ + { id: 1, type: 'tag', attributes: { name: 'first' } }, + { id: 2, type: 'tag', attributes: { name: 'second' } }, + { id: 3, type: 'tag', attributes: { name: 'third' } } + ]}; + }; + + env.adapter.findRecord = function(store, type, id, snapshot) { + if (type === Person) { + return { + data: { + id: 1, + type: 'person', + attributes: { name: 'Watson' }, + relationships: { + tags: { links: { related: 'person/1/tags'} } + } + } + }; + } else if (type === Tag) { + return { + data: { + id: 2, + type: 'tag', + attributes: { name: 'second' }, + relationships: { + person: { + data: { id: 1, type: 'person'} + } + } + } + }; + } else { + assert.true(false, 'wrong type') + } + }; + + return run(() => { + return store.findRecord('person', 1).then(person => { + assert.equal(get(person, 'name'), 'Watson', 'The person is now loaded'); + + // when I remove this findRecord the test passes + return store.findRecord('tag', 2).then(tag => { + assert.equal(get(tag, 'name'), 'second', 'The tag is now loaded'); + + return run(() => person.get('tags').then(tags => { + assert.equal(get(tags, 'length'), 3, 'the tags are all loaded'); + })); + }); + }); + }); +}); + testInDebug('throws assertion if of not set with an array', function(assert) { const Person = DS.Model.extend(); const Tag = DS.Model.extend({ diff --git a/tests/unit/system/relationships/relationship-payloads-test.js b/tests/unit/system/relationships/relationship-payloads-test.js index 6f7715760d0..a0f9a76e51a 100644 --- a/tests/unit/system/relationships/relationship-payloads-test.js +++ b/tests/unit/system/relationships/relationship-payloads-test.js @@ -1,36 +1,54 @@ import { get } from '@ember/object'; import { RelationshipPayloadsManager } from 'ember-data/-private'; import DS from 'ember-data'; -import { createStore } from 'dummy/tests/helpers/store'; +import setupStore from 'dummy/tests/helpers/store'; import { module, test } from 'qunit'; import testInDebug from 'dummy/tests/helpers/test-in-debug'; +import { run } from '@ember/runloop'; +import { + reset as resetModelFactoryInjection +} from 'dummy/tests/helpers/model-factory-injection'; + +const { + belongsTo, + hasMany, + attr, + Model +} = DS; module('unit/system/relationships/relationship-payloads', { beforeEach() { - const User = DS.Model.extend({ - purpose: DS.belongsTo('purpose', { inverse: 'user' }), - hobbies: DS.hasMany('hobby', { inverse: 'user'}), - friends: DS.hasMany('user', { inverse: 'friends' }) + const User = Model.extend({ + purpose: belongsTo('purpose', { inverse: 'user' }), + hobbies: hasMany('hobby', { inverse: 'user'}), + friends: hasMany('user', { inverse: 'friends' }) }); User.toString = () => 'User'; - const Hobby = DS.Model.extend({ - user: DS.belongsTo('user', { inverse: 'hobbies' }) + const Hobby = Model.extend({ + user: belongsTo('user', { inverse: 'hobbies' }) }); Hobby.toString = () => 'Hobby'; - const Purpose = DS.Model.extend({ - user: DS.belongsTo('user', { inverse: 'purpose' }) + const Purpose = Model.extend({ + user: belongsTo('user', { inverse: 'purpose' }) }); Purpose.toString = () => 'Purpose'; - let store = this.store = createStore({ + this.env = setupStore({ user: User, Hobby: Hobby, purpose: Purpose }); + let store = this.store = this.env.store; + this.relationshipPayloadsManager = new RelationshipPayloadsManager(store); + }, + + afterEach() { + resetModelFactoryInjection(); + run(this.env.container, 'destroy'); } }); @@ -144,3 +162,183 @@ testInDebug('unload asserts the passed modelName and relationshipName refer to t }, 'brand-of-catnip:purpose is not either side of this relationship, user:purpose<->purpose:user'); }); +module("Unit | Relationship Payloads | Merge Forward Links & Meta", { + beforeEach() { + const User = Model.extend({ + name: attr(), + pets: hasMany('pet', { async: true, inverse: 'owner' }), + home: belongsTo('home', { async: true, inverse: 'owners' }) + }); + const Home = Model.extend({ + address: attr(), + owners: hasMany('user', { async: true, inverse: 'home' }) + }); + const Pet = Model.extend({ + name: attr(), + owner: belongsTo('user', { async: false, inverse: 'pets' }) + }); + + this.env = setupStore({ + user: User, + pet: Pet, + home: Home + }); + + this.store = this.env.store; + }, + + afterEach() { + resetModelFactoryInjection(); + run(this.env.container, 'destroy'); + } +}); + +test('links and meta for hasMany inverses are not overwritten', function(assert) { + let { store } = this; + + // user:1 with pet:1 pet:2 and home:1 and links and meta for both + let user1 = run(() => store.push({ + data: { + type: 'user', + id: '1', + attributes: { name: '@runspired ' }, + relationships: { + home: { + links: { + related: './runspired/home' + }, + data: { + type: 'home', + id: '1' + }, + meta: { + slogan: 'home is where the <3 emoji is' + } + }, + pets: { + links: { + related: './runspired/pets' + }, + data: [ + { type: 'pet', id: '1' }, + { type: 'pet', id: '2' } + ], + meta: { + slogan: 'catz rewl rawr' + } + } + } + } + })); + + // home:1 with user:1 user:2 and links and meta + // user:2 sideloaded to prevent needing to fetch + let home1 = run(() => store.push({ + data: { + type: 'home', + id: '1', + attributes: { address: 'Oakland, CA' }, + relationships: { + owners: { + links: { + related: './home/1/owners' + }, + data: [ + { type: 'user', id: '2' }, + { type: 'user', id: '1' } + ], + meta: { + slogan: 'what is woof?' + } + } + } + }, + included: [ + { + type: 'user', + id: '2', + attribute: { name: '@hjdivad' }, + relationships: { + home: { + data: { type: 'home', id: '1' } + } + } + } + ] + })); + + // Toss a couple of pets in for good measure + run(() => store.push({ + data: [ + { + type: 'pet', + id:'1', + attributes: { name: 'Shen' }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + }, + { + type: 'pet', + id:'2', + attributes: { name: 'Rambo' }, + relationships: { + owner: { + data: { + type: 'user', + id: '1' + } + } + } + } + ] + })); + + run(() => user1.get('home')); + run(() => user1.get('pets')); + run(() => home1.get('owners')); + + assert.deepEqual( + user1.belongsTo('home').belongsToRelationship.meta, + { + slogan: 'home is where the <3 emoji is' + }, + `We merged forward meta for user 1's home` + ); + assert.deepEqual( + home1.hasMany('owners').hasManyRelationship.meta, + { + slogan: 'what is woof?' + }, + `We merged forward meta for home 1's owners` + ); + assert.deepEqual( + user1.hasMany('pets').hasManyRelationship.meta, + { + slogan: 'catz rewl rawr' + }, + `We merged forward meta for user 1's pets` + ); + + // check the link as best we can + assert.equal( + user1.belongsTo('home').belongsToRelationship.link, + './runspired/home', + `We merged forward links for user 1's home` + ); + assert.equal( + user1.hasMany('pets').hasManyRelationship.link, + './runspired/pets', + `We merged forward links for user 1's pets` + ); + assert.equal( + home1.hasMany('owners').hasManyRelationship.link, + './home/1/owners', + `We merged forward links for home 1's owners` + ); +});