From 6e8a23656cb073596cd951bdab9ca0541d1bcbe3 Mon Sep 17 00:00:00 2001 From: "David J. Hamilton" Date: Tue, 5 Dec 2017 08:06:28 -0800 Subject: [PATCH] Restore 2.12 semantics for sync unloadRecord A number of use cases rely on unloadRecord's 2.12 behaviour of treating unloadRecord as a client-side delete on the inverse side of a sync relationship. This pr restores that functionality, while retaining the invalidate+refetch functionality for async relationships. This behaviour is codified in a number of tests within tests/integration/records/unload-test.js Fix #5136, #5137 --- addon/-private/system/model/internal-model.js | 26 +- .../system/relationships/state/belongs-to.js | 19 + .../system/relationships/state/has-many.js | 61 +- .../relationships/state/relationship.js | 110 +- .../integration/records/rematerialize-test.js | 2 +- tests/integration/records/unload-test.js | 1301 ++++++++++++++++- .../relationships/many-to-many-test.js | 2 +- 7 files changed, 1436 insertions(+), 85 deletions(-) diff --git a/addon/-private/system/model/internal-model.js b/addon/-private/system/model/internal-model.js index 6c5d896dccb..d9ccb8f8e2f 100644 --- a/addon/-private/system/model/internal-model.js +++ b/addon/-private/system/model/internal-model.js @@ -62,12 +62,24 @@ function areAllModelsUnloaded(internalModels) { return true; } +// Handle dematerialization for relationship `rel`. In all cases, notify the +// relatinoship of the dematerialization: this is done so the relationship can +// notify its inverse which needs to update state +// +// If the inverse is sync, unloading this record is treated as a client-side +// delete, so we remove the inverse records from this relationship to +// disconnect the graph. Because it's not async, we don't need to keep around +// the internalModel as an id-wrapper for references and because the graph is +// disconnected we can actually destroy the internalModel when checking for +// orphaned models. function destroyRelationship(rel) { - if (rel._inverseIsAsync()) { - rel.removeInternalModelFromInverse(rel.inverseInternalModel); - rel.removeInverseRelationships(); - } else { - rel.removeCompletelyFromInverse(); + rel.internalModelDidDematerialize(); + + if (rel._inverseIsSync()) { + // disconnect the graph so that the sync inverse relationship does not + // prevent us from cleaning up during `_cleanupOrphanedInternalModels` + rel.removeAllInternalModelsFromOwn(); + rel.removeAllCanonicalInternalModelsFromOwn(); } } // this (and all heimdall instrumentation) will be stripped by a babel transform @@ -432,6 +444,7 @@ export default class InternalModel { */ _directlyRelatedInternalModels() { let array = []; + this._relationships.forEach((name, rel) => { array = array.concat(rel.members.list, rel.canonicalMembers.list); }); @@ -923,10 +936,7 @@ export default class InternalModel { this.__implicitRelationships = null; Object.keys(implicitRelationships).forEach((key) => { let rel = implicitRelationships[key]; - destroyRelationship(rel); - - rel.destroy(); }); } diff --git a/addon/-private/system/relationships/state/belongs-to.js b/addon/-private/system/relationships/state/belongs-to.js index 82a4d968856..ea3bda37abe 100644 --- a/addon/-private/system/relationships/state/belongs-to.js +++ b/addon/-private/system/relationships/state/belongs-to.js @@ -58,6 +58,7 @@ export default class BelongsToRelationship extends Relationship { } inverseDidDematerialize() { + super.inverseDidDematerialize(this.inverseInternalModel); this.notifyBelongsToChanged(); } @@ -74,6 +75,13 @@ export default class BelongsToRelationship extends Relationship { } } + + removeCompletelyFromInverse() { + super.removeCompletelyFromInverse(); + + this.inverseInternalModel = null; + } + flushCanonical() { //temporary fix to not remove newly created records if server returned null. //TODO remove once we have proper diffing @@ -115,6 +123,12 @@ export default class BelongsToRelationship extends Relationship { this.notifyBelongsToChanged(); } + removeAllInternalModelsFromOwn() { + super.removeAllInternalModelsFromOwn(); + this.inverseInternalModel = null; + this.notifyBelongsToChanged(); + } + notifyBelongsToChanged() { this.internalModel.notifyBelongsToChanged(this.key); } @@ -125,6 +139,11 @@ export default class BelongsToRelationship extends Relationship { super.removeCanonicalInternalModelFromOwn(internalModel); } + removeAllCanonicalInternalModelsFromOwn() { + super.removeAllCanonicalInternalModelsFromOwn(); + this.canonicalState = null; + } + findRecord() { if (this.inverseInternalModel) { return this.store._findByInternalModel(this.inverseInternalModel); diff --git a/addon/-private/system/relationships/state/has-many.js b/addon/-private/system/relationships/state/has-many.js index 2062e8ba11c..962a58ffbeb 100755 --- a/addon/-private/system/relationships/state/has-many.js +++ b/addon/-private/system/relationships/state/has-many.js @@ -11,7 +11,12 @@ export default class ManyRelationship extends Relationship { this.belongsToType = relationshipMeta.type; this.canonicalState = []; this.isPolymorphic = relationshipMeta.options.polymorphic; + // The ManyArray for this relationship this._manyArray = null; + // The previous ManyArray for this relationship. It will be destroyed when + // 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; } @@ -33,6 +38,8 @@ export default class ManyRelationship extends Relationship { } get manyArray() { + assert(`Error: relationship ${this.parentType}:${this.key} has both many array and retained many array`, this._manyArray === null || this._retainedManyArray === null); + if (!this._manyArray) { this._manyArray = ManyArray.create({ canonicalState: this.canonicalState, @@ -43,7 +50,13 @@ export default class ManyRelationship extends Relationship { meta: this.meta, isPolymorphic: this.isPolymorphic }); + + if (this._retainedManyArray !== null) { + this._retainedManyArray.destroy(); + this._retainedManyArray = null; + } } + return this._manyArray; } @@ -78,10 +91,14 @@ export default class ManyRelationship extends Relationship { super.addCanonicalInternalModel(internalModel, idx); } - inverseDidDematerialize() { - if (this._manyArray) { - this._manyArray.destroy(); - this._manyArray = null; + inverseDidDematerialize(inverseInternalModel) { + super.inverseDidDematerialize(inverseInternalModel); + if (this.isAsync) { + if (this._manyArray) { + this._retainedManyArray = this._manyArray; + this._manyArray = null; + } + this._removeInternalModelFromManyArray(this._retainedManyArray, inverseInternalModel); } this.notifyHasManyChanged(); } @@ -111,6 +128,12 @@ export default class ManyRelationship extends Relationship { super.removeCanonicalInternalModelFromOwn(internalModel, idx); } + removeAllCanonicalInternalModelsFromOwn() { + super.removeAllCanonicalInternalModelsFromOwn(); + this.canonicalMembers.clear(); + this.canonicalState.splice(0, this.canonicalState.length); + } + removeCompletelyFromOwn(internalModel) { super.removeCompletelyFromOwn(internalModel); @@ -143,7 +166,33 @@ export default class ManyRelationship extends Relationship { return; } super.removeInternalModelFromOwn(internalModel, idx); - let manyArray = this.manyArray; + // note that ensuring the many array is created, via `this.manyArray` + // (instead of `this._manyArray`) is intentional. + // + // Because we're removing from local, and not canonical, state, it is + // important that the many array is initialized now with those changes, + // otherwise it will be initialized with canonical state and we'll have + // lost the fact that this internalModel was removed. + this._removeInternalModelFromManyArray(this.manyArray, internalModel, idx); + this._removeInternalModelFromManyArray(this._retainedManyArray, internalModel, idx); + } + + removeAllInternalModelsFromOwn() { + super.removeAllInternalModelsFromOwn(); + // as with removeInternalModelFromOwn, we make sure the many array is + // instantiated, or we'll lose local removals, as we're not updating + // canonical state here. + this.manyArray.clear(); + if (this._retainedManyArray) { + this._retainedManyArray.clear(); + } + } + + _removeInternalModelFromManyArray(manyArray, internalModel, idx) { + if (manyArray === null) { + return; + } + if (idx !== undefined) { //TODO(Igor) not used currently, fix manyArray.currentState.removeAt(idx); @@ -292,12 +341,14 @@ export default class ManyRelationship extends Relationship { let manyArray = this._manyArray; if (manyArray) { manyArray.destroy(); + this._manyArray = null; } let proxy = this.__loadingPromise; if (proxy) { proxy.destroy(); + this.__loadingPromise = null; } } } diff --git a/addon/-private/system/relationships/state/relationship.js b/addon/-private/system/relationships/state/relationship.js index 90ce273b424..5891d8ed8b3 100644 --- a/addon/-private/system/relationships/state/relationship.js +++ b/addon/-private/system/relationships/state/relationship.js @@ -1,5 +1,6 @@ /* global heimdall */ import { guidFor } from '@ember/object/internals'; +import { get } from '@ember/object'; import { assert, warn } from '@ember/debug'; import OrderedSet from '../../ordered-set'; @@ -76,35 +77,67 @@ export default class Relationship { this.meta = null; this.hasData = false; this.hasLoaded = false; + this.__inverseMeta = undefined; } - get parentType() { - return this.internalModel.modelName; + _inverseIsAsync() { + let inverseMeta = this._inverseMeta; + if (!inverseMeta) { + return false; + } + + let inverseAsync = inverseMeta.options.async; + return typeof inverseAsync === 'undefined' ? true : inverseAsync; } - _inverseIsAsync() { - if (!this.inverseKey || !this.inverseInternalModel) { + _inverseIsSync() { + let inverseMeta = this._inverseMeta; + if (!inverseMeta) { return false; } - return this.inverseInternalModel._relationships.get(this.inverseKey).isAsync; + + let inverseAsync = inverseMeta.options.async; + return typeof inverseAsync === 'undefined' ? false : !inverseAsync; } - removeInverseRelationships() { - if (!this.inverseKey) { return; } + get _inverseMeta() { + if (this.__inverseMeta === undefined) { + let inverseMeta = null; - let allMembers = - // we actually want a union of members and canonicalMembers - // they should be disjoint but currently are not due to a bug - this.members.list.concat(this.canonicalMembers.list); + if (this.inverseKey) { + let inverseModelClass = this.store.modelFor(this.relationshipMeta.type); + let inverseRelationships = get(inverseModelClass, 'relationshipsByName'); + inverseMeta = inverseRelationships.get(this.inverseKey); + } - for (let i = 0; i < allMembers.length; i++) { - let inverseInternalModel = allMembers[i]; - let relationship = inverseInternalModel._relationships.get(this.inverseKey); - relationship.inverseDidDematerialize(); + this.__inverseMeta = inverseMeta; } + + return this.__inverseMeta; + } + + get parentType() { + return this.internalModel.modelName; + } + + internalModelDidDematerialize() { + if (!this.inverseKey) { return; } + + this.forAllMembers((inverseInternalModel) => { + let relationship = inverseInternalModel._relationships.get(this.inverseKey); + relationship.inverseDidDematerialize(this.internalModel); + }); } - inverseDidDematerialize() {} + inverseDidDematerialize(inverseInternalModel) { + 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 + // cache. + this.removeInternalModelFromOwn(inverseInternalModel); + this.removeCanonicalInternalModelFromOwn(inverseInternalModel); + } + } updateMeta(meta) { heimdall.increment(updateMeta); @@ -127,6 +160,16 @@ export default class Relationship { } } + removeAllInternalModelsFromOwn() { + this.members.clear(); + this.internalModel.updateRecordArrays(); + } + + removeAllCanonicalInternalModelsFromOwn() { + this.canonicalMembers.clear(); + this.flushCanonicalLater(); + } + removeInternalModels(internalModels) { heimdall.increment(removeInternalModels); internalModels.forEach((internalModel) => this.removeInternalModel(internalModel)); @@ -181,7 +224,7 @@ export default class Relationship { let relationship = relationships[this.inverseKeyForImplicit]; if (!relationship) { relationship = relationships[this.inverseKeyForImplicit] = - new Relationship(this.store, internalModel, this.key, { options: { async: this.isAsync } }); + new Relationship(this.store, internalModel, this.key, { options: { async: this.isAsync }, type: this.parentType }); } relationship.addCanonicalInternalModel(this.internalModel); } @@ -222,7 +265,12 @@ export default class Relationship { internalModel._relationships.get(this.inverseKey).addInternalModel(this.internalModel); } else { if (!internalModel._implicitRelationships[this.inverseKeyForImplicit]) { - internalModel._implicitRelationships[this.inverseKeyForImplicit] = new Relationship(this.store, internalModel, this.key, { options: { async: this.isAsync } }); + internalModel._implicitRelationships[this.inverseKeyForImplicit] = new Relationship( + this.store, + internalModel, + this.key, + { options: { async: this.isAsync }, type: this.parentType } + ); } internalModel._implicitRelationships[this.inverseKeyForImplicit].addInternalModel(this.internalModel); } @@ -303,6 +351,32 @@ export default class Relationship { this.members.forEach(unload); this.canonicalMembers.forEach(unload); + + if (!this.isAsync) { + this.clear(); + } + } + + forAllMembers(callback) { + let seen = Object.create(null); + + for (let i = 0; i < this.members.list.length; i++) { + const inverseInternalModel = this.members.list[i]; + const id = guidFor(inverseInternalModel); + if (!seen[id]) { + seen[id] = true; + callback(inverseInternalModel); + } + } + + for (let i = 0; i < this.canonicalMembers.list.length; i++) { + const inverseInternalModel = this.canonicalMembers.list[i]; + const id = guidFor(inverseInternalModel); + if (!seen[id]) { + seen[id] = true; + callback(inverseInternalModel); + } + } } /* diff --git a/tests/integration/records/rematerialize-test.js b/tests/integration/records/rematerialize-test.js index 0e7917043b6..416e299f24c 100644 --- a/tests/integration/records/rematerialize-test.js +++ b/tests/integration/records/rematerialize-test.js @@ -110,7 +110,7 @@ test("a sync belongs to relationship to an unloaded record can restore that reco run(() => person.unloadRecord()); assert.equal(env.store.hasRecordForId('person', 1), false, 'The person is unloaded'); - assert.equal(env.store._internalModelsFor('person').has(1), true, 'The person internalModel is retained'); + assert.equal(env.store._internalModelsFor('person').has(1), false, 'The person internalModel is freed'); run(() => { env.store.push({ diff --git a/tests/integration/records/unload-test.js b/tests/integration/records/unload-test.js index 7568ea66cdb..3afc287f901 100644 --- a/tests/integration/records/unload-test.js +++ b/tests/integration/records/unload-test.js @@ -17,12 +17,43 @@ let env; let Person = DS.Model.extend({ name: attr('string'), + // 1:many sync cars: hasMany('car', { async: false }), + // 1:many async boats: hasMany('boat', { async: true }), - bike: belongsTo('boat', { async: false, inverse: null }) + // many:many sync + groups: hasMany('group', { async: false }), + // many:many async + friends: hasMany('people', { async: true }), + // 1:1 sync inverse null + bike: belongsTo('bike', { async: false, inverse: null }), + // 1:1 sync + house: belongsTo('house', { async: false }), + // 1:1 async + mortgage: belongsTo('mortgage', { async: true }), + // 1 async : 1 sync + favoriteBook: belongsTo('book', { async: false }), + // 1 async : many sync + favoriteSpoons: hasMany('spoon', { async: false }), + // 1 sync: many async + favoriteShows: hasMany('show', { async: true }), + // many sync : many async + favoriteFriends: hasMany('people', { async: true, inverse: 'favoriteAsyncFriends' }), + // many async : many sync + favoriteAsyncFriends: hasMany('people', { async: false, inverse: 'favoriteFriends' }) }); Person.reopenClass({ toString() { return 'Person'; } }); +let House = DS.Model.extend({ + person: belongsTo('person', { async: false }) +}); +House.reopenClass({ toString() { return 'House'; } }); + +let Mortgage = DS.Model.extend({ + person: belongsTo('person', { async: true }) +}); +Mortgage.reopenClass({ toString() { return 'Mortgage'; } }); + let Group = DS.Model.extend({ people: hasMany('person', { async: false }) }); @@ -37,7 +68,7 @@ Car.reopenClass({ toString() { return 'Car'; } }); let Boat = DS.Model.extend({ name: attr('string'), - person: belongsTo('person', { async: false }) + person: belongsTo('person', { async: true }) }); Boat.toString = function() { return 'Boat'; }; @@ -46,6 +77,21 @@ let Bike = DS.Model.extend({ }); Bike.toString = function() { return 'Bike'; }; +let Book = DS.Model.extend({ + person: belongsTo('person', { async: true }) +}); +Book.toString = function() { return 'Book'; }; + +let Spoon = DS.Model.extend({ + person: belongsTo('person', { async: true }) +}); +Spoon.toString = function() { return 'Spoon'; }; + +let Show = DS.Model.extend({ + person: belongsTo('person', { async: false }) +}); +Show.toString = function() { return 'Show'; }; + module("integration/unload - Unloading Records", { beforeEach() { env = setupStore({ @@ -53,8 +99,13 @@ module("integration/unload - Unloading Records", { person: Person, car: Car, group: Group, + house: House, + mortgage: Mortgage, boat: Boat, - bike: Bike + bike: Bike, + book: Book, + spoon: Spoon, + show: Show }); }, @@ -325,10 +376,10 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu name: 'Could be Anybody' }, relationships: { - cars: { + boats: { data: [ - { type: 'car', id: '1' }, - { type: 'car', id: '2' } + { type: 'boat', id: '1' }, + { type: 'boat', id: '2' } ] } } @@ -339,11 +390,10 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu run(() => { env.store.push({ data: { - type: 'car', + type: 'boat', id: '1', attributes: { - make: 'Nissan', - model: 'Altima' + name: 'Boaty McBoatface' }, relationships: { person: { @@ -357,11 +407,10 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu run(() => { env.store.push({ data: { - type: 'car', + type: 'boat', id: '2', attributes: { - make: 'Tesla', - model: 'S' + name: 'The jackson' }, relationships: { person: { @@ -378,17 +427,17 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu 'one person record is loaded' ); assert.equal( - env.store._internalModelsFor('car').models.length, + env.store._internalModelsFor('boat').models.length, 2, - 'two car records are loaded' + 'two boat records are loaded' ); assert.equal(env.store.hasRecordForId('person', 1), true); - assert.equal(env.store.hasRecordForId('car', 1), true); - assert.equal(env.store.hasRecordForId('car', 2), true); + assert.equal(env.store.hasRecordForId('boat', 1), true); + assert.equal(env.store.hasRecordForId('boat', 2), true); let relPayloads = env.store._relationshipsPayloads; - assert.equal(relPayloads.get('person', 1, 'cars').data.length, 2, 'person - cars relationship payload loaded'); + assert.equal(relPayloads.get('person', 1, 'boats').data.length, 2, 'person - boats relationship payload loaded'); let checkOrphanCalls = 0; let cleanupOrphanCalls = 0; @@ -408,25 +457,25 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu }; } countOrphanCalls(env.store.peekRecord('person', 1)); - countOrphanCalls(env.store.peekRecord('car', 1)); - countOrphanCalls(env.store.peekRecord('car', 2)); + countOrphanCalls(env.store.peekRecord('boat', 1)); + countOrphanCalls(env.store.peekRecord('boat', 2)); // make sure relationships are initialized - env.store.peekRecord('person', 1).get('cars'); - - run(() => { - env.store.peekRecord('person', 1).unloadRecord(); - env.store.peekRecord('car', 1).unloadRecord(); - env.store.peekRecord('car', 2).unloadRecord(); - }); + return env.store.peekRecord('person', 1).get('boats').then(() => { + run(() => { + env.store.peekRecord('person', 1).unloadRecord(); + env.store.peekRecord('boat', 1).unloadRecord(); + env.store.peekRecord('boat', 2).unloadRecord(); + }); - assert.equal(env.store._internalModelsFor('person').models.length, 0); - assert.equal(env.store._internalModelsFor('car').models.length, 0); + assert.equal(env.store._internalModelsFor('person').models.length, 0); + assert.equal(env.store._internalModelsFor('boat').models.length, 0); - assert.equal(checkOrphanCalls, 3, 'each internalModel checks for cleanup'); - assert.equal(cleanupOrphanCalls, 1, 'cleanup only happens once'); + assert.equal(checkOrphanCalls, 3, 'each internalModel checks for cleanup'); + assert.equal(cleanupOrphanCalls, 1, 'cleanup only happens once'); - assert.equal(relPayloads.get('person', 1, 'cars'), null, 'person - cars relationship payload unloaded'); + assert.equal(relPayloads.get('person', 1, 'boats'), null, 'person - boats relationship payload unloaded'); + }); }); @@ -766,7 +815,7 @@ test('after unloading a record, the record can be saved again immediately', func }); }); -test('after unloading a record, pushing a new copy will setup relatioonships', function (assert) { +test('after unloading a record, pushing a new copy will setup relationships', function (assert) { const store = env.store; const personData = { data: { @@ -777,37 +826,1185 @@ test('after unloading a record, pushing a new copy will setup relatioonships', f } } }; - const carData = { - data: { - type: 'car', - id: '10', - attributes: { - make: 'VW', - model: 'Beetle' - }, - relationships: { - person: { - data: { type: 'person', id: '1' } - } - } - } - }; function pushCar() { - store.push(Ember.copy(carData, true)); + store.push({ + data: { + type: 'car', + id: '10', + attributes: { + make: 'VW', + model: 'Beetle' + }, + relationships: { + person: { + data: { type: 'person', id: '1' } + } + } + } + }); } - Ember.run(() => { store.push(personData) }); + run(() => { store.push(personData) }); let adam = env.store.peekRecord('person', 1); assert.equal(adam.get('cars.length'), 0, 'cars hasMany starts off empty'); - Ember.run(() => pushCar()); + run(() => pushCar()); assert.equal(adam.get('cars.length'), 1, 'pushing car setups inverse relationship'); - Ember.run(() => adam.get('cars.firstObject').unloadRecord()); + run(() => adam.get('cars.firstObject').unloadRecord()); assert.equal(adam.get('cars.length'), 0, 'unloading car cleaned up hasMany'); - Ember.run(() => pushCar()); + run(() => pushCar()); assert.equal(adam.get('cars.length'), 1, 'pushing car again setups inverse relationship'); }); + +test('1:1 sync unload', function (assert) { + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + house: { + data: { + id: 2, + type: 'house' + } + } + } + }, + included: [{ + id: 2, + type: 'house' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let house = env.store.peekRecord('house', 2); + + assert.equal(person.get('house.id'), 2, 'initially relationship established lhs'); + assert.equal(house.get('person.id'), 1, 'initially relationship established rhs'); + + run(() => house.unloadRecord()); + + + assert.equal(person.get('house'), null, 'unloading acts as a delete for sync relationships'); + assert.equal(env.store.hasRecordForId('house', 2), false, 'unloaded record gone from store'); + + house = run(() => + env.store.push({ + data: { + id: 2, + type: 'house' + } + }) + ); + + assert.equal(env.store.hasRecordForId('house', 2), true, 'unloaded record can be restored'); + assert.equal(person.get('house'), null, 'restoring unloaded record does not restore relationship'); + assert.equal(house.get('person'), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 2, + type: 'house', + relationships: { + person: { + data: { + id: 1, + type: 'person' + } + } + } + } + }) + ); + + assert.equal(person.get('house.id'), 2, 'after unloading, relationship can be restored'); + assert.equal(house.get('person.id'), 1, 'after unloading, relationship can be restored'); +}); + +test('1:many sync unload 1 side', function (assert) { + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + cars: { + data: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + } + } + }, + included: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let car2 = env.store.peekRecord('car', 2); + let car3 = env.store.peekRecord('car', 3); + let cars = person.get('cars'); + + assert.equal(cars.isDestroyed, false, 'ManyArray not destroyed'); + assert.deepEqual(person.get('cars').mapBy('id'), ['2', '3'], 'initialy relationship established lhs'); + assert.equal(car2.get('person.id'), 1, 'initially relationship established rhs'); + assert.equal(car3.get('person.id'), 1, 'initially relationship established rhs'); + + run(() => person.unloadRecord()); + + assert.equal(env.store.hasRecordForId('person', 1), false, 'unloaded record gone from store'); + + assert.equal(car2.get('person'), null, 'unloading acts as delete for sync relationships'); + assert.equal(car3.get('person'), null, 'unloading acts as delete for sync relationships'); + assert.equal(cars.isDestroyed, true, 'ManyArray destroyed'); + + person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person' + } + }) + ); + + assert.equal(env.store.hasRecordForId('person', 1), true, 'unloaded record can be restored'); + assert.deepEqual(person.get('cars').mapBy('id'), [], 'restoring unloaded record does not restore relationship'); + assert.equal(car2.get('person'), null, 'restoring unloaded record does not restore relationship'); + assert.equal(car3.get('person'), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + cars: { + data: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + } + } + } + }) + ); + + assert.equal(car2.get('person.id'), '1', 'after unloading, relationship can be restored'); + assert.equal(car3.get('person.id'), '1', 'after unloading, relationship can be restored'); + assert.deepEqual(person.get('cars').mapBy('id'), ['2', '3'], 'after unloading, relationship can be restored'); +}); + +test('1:many sync unload many side', function (assert) { + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + cars: { + data: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + } + } + }, + included: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let car2 = env.store.peekRecord('car', 2); + let car3 = env.store.peekRecord('car', 3); + let cars = person.get('cars'); + + assert.equal(cars.isDestroyed, false, 'ManyArray not destroyed'); + assert.deepEqual(person.get('cars').mapBy('id'), ['2', '3'], 'initialy relationship established lhs'); + assert.equal(car2.get('person.id'), 1, 'initially relationship established rhs'); + assert.equal(car3.get('person.id'), 1, 'initially relationship established rhs'); + + run(() => car2.unloadRecord()); + + assert.equal(env.store.hasRecordForId('car', 2), false, 'unloaded record gone from store'); + + assert.equal(cars.isDestroyed, false, 'ManyArray not destroyed'); + assert.deepEqual(person.get('cars').mapBy('id'), ['3'], 'unload sync relationship acts as delete'); + assert.equal(car3.get('person.id'), '1', 'unloading one of a sync hasMany does not affect the rest'); + + car2 = run(() => + env.store.push({ + data: { + id: 2, + type: 'car' + } + }) + ); + + assert.equal(env.store.hasRecordForId('car', 2), true, 'unloaded record can be restored'); + assert.deepEqual(person.get('cars').mapBy('id'), ['3'], 'restoring unloaded record does not restore relationship'); + assert.equal(car2.get('person'), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + cars: { + data: [{ + id: 2, + type: 'car' + }, { + id: 3, + type: 'car' + }] + } + } + } + }) + ); + + assert.equal(car2.get('person.id'), '1', 'after unloading, relationship can be restored'); + assert.deepEqual(person.get('cars').mapBy('id'), ['2', '3'], 'after unloading, relationship can be restored'); +}); + +test('many:many sync unload', function (assert) { + run(() => + env.store.push({ + data: [{ + id: 1, + type: 'person', + relationships: { + groups: { + data: [{ + id: 3, + type: 'group' + }, { + id: 4, + type: 'group' + }] + } + } + }, { + id: 2, + type: 'person', + relationships: { + groups: { + data: [{ + id: 3, + type: 'group' + }, { + id: 4, + type: 'group' + }] + } + } + }], + included: [{ + id: 3, + type: 'group' + }, { + id: 4, + type: 'group' + }] + }) + ); + + let person1 = env.store.peekRecord('person', 1); + let person2 = env.store.peekRecord('person', 2); + let group3 = env.store.peekRecord('group', 3); + let group4 = env.store.peekRecord('group', 4); + let p2groups = person2.get('groups'); + let g3people = group3.get('people'); + + assert.deepEqual(person1.get('groups').mapBy('id'), ['3', '4'], 'initially established relationship lhs'); + assert.deepEqual(person2.get('groups').mapBy('id'), ['3', '4'], 'initially established relationship lhs'); + assert.deepEqual(group3.get('people').mapBy('id'), ['1', '2'], 'initially established relationship lhs'); + assert.deepEqual(group4.get('people').mapBy('id'), ['1', '2'], 'initially established relationship lhs'); + + assert.equal(p2groups.isDestroyed, false, 'groups is not destroyed'); + assert.equal(g3people.isDestroyed, false, 'people is not destroyed'); + + run(() => person2.unloadRecord()); + + assert.equal(p2groups.isDestroyed, true, 'groups (unloaded side) is destroyed'); + assert.equal(g3people.isDestroyed, false, 'people (inverse) is not destroyed'); + + assert.deepEqual(person1.get('groups').mapBy('id'), ['3', '4'], 'unloaded record in many:many does not affect inverse of inverse'); + assert.deepEqual(group3.get('people').mapBy('id'), ['1'], 'unloading acts as delete for sync relationships'); + assert.deepEqual(group4.get('people').mapBy('id'), ['1'], 'unloading acts as delete for sync relationships'); + + assert.equal(env.store.hasRecordForId('person', 2), false, 'unloading removes record from store'); + + person2 = run(() => + env.store.push({ + data: { + id: 2, + type: 'person' + } + }) + ); + + assert.equal(env.store.hasRecordForId('person', 2), true, 'unloaded record can be restored'); + assert.deepEqual(person2.get('groups').mapBy('id'), [], 'restoring unloaded record does not restore relationship'); + assert.deepEqual(group3.get('people').mapBy('id'), ['1'], 'restoring unloaded record does not restore relationship'); + assert.deepEqual(group4.get('people').mapBy('id'), ['1'], 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 2, + type: 'person', + relationships: { + groups: { + data: [{ + id: 3, + type: 'group' + }, { + id: 4, + type: 'group' + }] + } + } + } + }) + ); + + assert.deepEqual(person2.get('groups').mapBy('id'), ['3', '4'], 'after unloading, relationship can be restored'); + assert.deepEqual(group3.get('people').mapBy('id'), ['1', '2'], 'after unloading, relationship can be restored'); + assert.deepEqual(group4.get('people').mapBy('id'), ['1', '2'], 'after unloading, relationship can be restored'); +}); + +test('1:1 async unload', function (assert) { + let findRecordCalls = 0; + + env.adapter.findRecord = (store, type, id) => { + assert.equal(type, Mortgage, 'findRecord(_, type) is correct'); + assert.equal(id, '2', 'findRecord(_, _, id) is correct'); + ++findRecordCalls; + + return { + data: { + id: 2, + type: 'mortgage' + } + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + mortgage: { + data: { + id: 2, + type: 'mortgage' + } + } + } + } + }) + ); + let mortgage; + + return run(() => + person.get('mortgage').then((asyncRecord) => { + mortgage = asyncRecord; + return mortgage.get('person'); + }).then(() => { + assert.equal(mortgage.belongsTo('person').id(), '1', 'initially relationship established lhs'); + assert.equal(person.belongsTo('mortgage').id(), '2', 'initially relationship established rhs'); + + run(() => mortgage.unloadRecord()); + + assert.equal(person.belongsTo('mortgage').id(), '2', 'unload async is not treated as delete'); + + return person.get('mortgage'); + }).then((refetchedMortgage) => { + assert.notEqual(mortgage, refetchedMortgage, 'the previously loaded record is not reused'); + + assert.equal(person.belongsTo('mortgage').id(), '2', 'unload async is not treated as delete'); + assert.equal(refetchedMortgage.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(findRecordCalls, 2); + }) + ); +}); + +test('1:many async unload 1 side', function (assert) { + let findRecordCalls = 0; + let findManyCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findRecord = (store, type, id) => { + assert.equal(type, Person, 'findRecord(_, type) is correct'); + assert.deepEqual(id, '1', 'findRecord(_, _, id) is correct'); + ++findRecordCalls; + + return { + data: { + id: 1, + type: 'person' + } + }; + }; + + env.adapter.findMany = (store, type, ids) => { + assert.equal(type+'', Boat+'', 'findMany(_, type) is correct'); + assert.deepEqual(ids, ['2', '3'], 'findMany(_, _, ids) is correct'); + ++findManyCalls; + + return { + data: [{ + id: 2, + type: 'boat' + }, { + id: 3, + type: 'boat' + }] + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + boats: { + data: [{ + id: 2, + type: 'boat' + }, { + id: 3, + type: 'boat' + }] + } + } + } + }) + ); + let boats, boat2, boat3; + + return run(() => + person.get('boats').then((asyncRecords) => { + boats = asyncRecords; + [boat2, boat3] = boats.toArray(); + return EmberPromise.all([boat2, boat3].map(b => b.get('person'))); + }).then(() => { + assert.deepEqual(person.hasMany('boats').ids(), ['2', '3'], 'initially relationship established lhs'); + assert.equal(boat2.belongsTo('person').id(), '1', 'initially relationship established rhs'); + assert.equal(boat3.belongsTo('person').id(), '1', 'initially relationship established rhs'); + + assert.equal(boats.isDestroyed, false, 'ManyArray is not destroyed'); + + run(() => person.unloadRecord()); + + assert.equal(boats.isDestroyed, false, 'ManyArray is not destroyed when 1 side is unloaded'); + assert.equal(boat2.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(boat3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + return boat2.get('person'); + }).then((refetchedPerson) => { + assert.notEqual(person, refetchedPerson, 'the previously loaded record is not reused'); + + assert.deepEqual(person.hasMany('boats').ids(), ['2', '3'], 'unload async is not treated as delete'); + assert.equal(boat2.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(boat3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + assert.equal(findManyCalls, 1, 'findMany called as expected'); + assert.equal(findRecordCalls, 1, 'findRecord called as expected'); + }) + ); +}); + +test('1:many async unload many side', function (assert) { + let findManyCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findMany = (store, type, ids) => { + assert.equal(type+'', Boat+'', 'findMany(_, type) is correct'); + assert.deepEqual(ids, ['2', '3'], 'findMany(_, _, ids) is correct'); + ++findManyCalls; + + return { + data: [{ + id: 2, + type: 'boat' + }, { + id: 3, + type: 'boat' + }] + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + boats: { + data: [{ + id: 2, + type: 'boat' + }, { + id: 3, + type: 'boat' + }] + } + } + } + }) + ); + let boats, boat2, boat3; + + return run(() => + person.get('boats').then((asyncRecords) => { + boats = asyncRecords; + [boat2, boat3] = boats.toArray(); + return EmberPromise.all([boat2, boat3].map(b => b.get('person'))); + }).then(() => { + assert.deepEqual(person.hasMany('boats').ids(), ['2', '3'], 'initially relationship established lhs'); + assert.equal(boat2.belongsTo('person').id(), '1', 'initially relationship established rhs'); + assert.equal(boat3.belongsTo('person').id(), '1', 'initially relationship established rhs'); + + assert.deepEqual(boats.mapBy('id'), ['2', '3'], 'many array is initially set up correctly'); + run(() => boat2.unloadRecord()); + assert.deepEqual(boats.mapBy('id'), ['3'], 'unload async removes from previous many array'); + assert.equal(boats.isDestroyed, false, 'previous ManyArray not destroyed'); + + run(() => boat3.unloadRecord()); + assert.deepEqual(boats.mapBy('id'), [], 'unload async removes from previous many array'); + assert.equal(boats.isDestroyed, false, 'previous ManyArray not destroyed'); + + assert.deepEqual(person.hasMany('boats').ids(), ['2', '3'], 'unload async is not treated as delete'); + assert.equal(boat3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + return person.get('boats'); + }).then((refetchedBoats) => { + assert.equal(boats.isDestroyed, false, 'previous ManyArray is not immediately destroyed after refetch'); + assert.equal(boats.isDestroying, true, 'previous ManyArray is being destroyed immediately after refetch'); + assert.deepEqual(refetchedBoats.mapBy('id'), ['2', '3'], 'boats refetched'); + assert.deepEqual(person.hasMany('boats').ids(), ['2', '3'], 'unload async is not treated as delete'); + assert.equal(boat3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + assert.equal(findManyCalls, 2, 'findMany called as expected'); + }) + ).then(() => { + assert.equal(boats.isDestroyed, true, 'previous ManyArray is destroyed in the runloop after refetching'); + }); +}); + +test('many:many async unload', function (assert) { + let findManyCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findMany = (store, type, ids) => { + assert.equal(type+'', Person+'', 'findMany(_, type) is correct'); + assert.deepEqual(ids, ['3', '4'], 'findMany(_, _, ids) is correct'); + ++findManyCalls; + + return { + data: [{ + id: 3, + type: 'person' + }, { + id: 4, + type: 'person' + }] + }; + }; + + let [person1, person2] = run(() => + env.store.push({ + data: [{ + id: 1, + type: 'person', + relationships: { + friends: { + data: [{ + id: 3, + type: 'person' + }, { + id: 4, + type: 'person' + }] + } + } + }, { + id: 2, + type: 'person', + relationships: { + friends: { + data: [{ + id: 3, + type: 'person' + }, { + id: 4, + type: 'person' + }] + } + } + }] + }) + ); + + let person1Friends, person3, person4; + + return run(() => + person1.get('friends').then((asyncRecords) => { + person1Friends = asyncRecords; + [person3, person4] = person1Friends.toArray(); + return EmberPromise.all([person2, person3, person4].map(b => b.get('friends'))); + }).then(() => { + assert.deepEqual(person1.hasMany('friends').ids(), ['3', '4'], 'initially relationship established lhs'); + assert.deepEqual(person2.hasMany('friends').ids(), ['3', '4'], 'initially relationship established lhs'); + assert.deepEqual(person3.hasMany('friends').ids(), ['1', '2'], 'initially relationship established rhs'); + assert.deepEqual(person4.hasMany('friends').ids(), ['1', '2'], 'initially relationship established rhs'); + + run(() => person3.unloadRecord()); + assert.deepEqual(person1Friends.mapBy('id'), ['4'], 'unload async removes from previous many array'); + assert.equal(person1Friends.isDestroyed, false, 'previous ManyArray not destroyed'); + + run(() => person4.unloadRecord()); + assert.deepEqual(person1Friends.mapBy('id'), [], 'unload async removes from previous many array'); + assert.equal(person1Friends.isDestroyed, false, 'previous ManyArray not destroyed'); + + assert.deepEqual(person1.hasMany('friends').ids(), ['3', '4'], 'unload async is not treated as delete'); + + return person1.get('friends'); + }).then((refetchedFriends) => { + assert.equal(person1Friends.isDestroyed, false, 'previous ManyArray is not immediately destroyed after refetch'); + assert.equal(person1Friends.isDestroying, true, 'previous ManyArray is being destroyed immediately after refetch'); + assert.deepEqual(refetchedFriends.mapBy('id'), ['3', '4'], 'friends refetched'); + assert.deepEqual(person1.hasMany('friends').ids(), ['3', '4'], 'unload async is not treated as delete'); + + assert.deepEqual(refetchedFriends.map(p => p.hasMany('friends').ids()), [ + ['1', '2'], + ['1', '2'] + ], 'unload async is not treated as delete'); + + assert.equal(findManyCalls, 2, 'findMany called as expected'); + }) + ).then(() => { + assert.equal(person1Friends.isDestroyed, true, 'previous ManyArray is destroyed in the runloop after refetching'); + }); +}); + +test('1 sync : 1 async unload sync side', function (assert) { + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteBook: { + data: { + id: 2, + type: 'book' + } + } + } + }, + included: [{ + id: 2, + type: 'book' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let book = env.store.peekRecord('book', 2); + + return book.get('person').then(() => { + assert.equal(person.get('favoriteBook.id'), 2, 'initially relationship established lhs'); + assert.equal(book.belongsTo('person').id(), 1, 'initially relationship established rhs'); + + run(() => book.unloadRecord()); + + assert.equal(person.get('book'), null, 'unloading acts as a delete for sync relationships'); + assert.equal(env.store.hasRecordForId('book', 2), false, 'unloaded record gone from store'); + + book = run(() => + env.store.push({ + data: { + id: 2, + type: 'book' + } + }) + ); + + assert.equal(env.store.hasRecordForId('book', 2), true, 'unloaded record can be restored'); + assert.equal(person.get('book'), null, 'restoring unloaded record does not restore relationship'); + assert.equal(book.belongsTo('person').id(), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 2, + type: 'book', + relationships: { + person: { + data: { + id: 1, + type: 'person' + } + } + } + } + }) + ); + + assert.equal(person.get('favoriteBook.id'), 2, 'after unloading, relationship can be restored'); + assert.equal(book.get('person.id'), 1, 'after unloading, relationship can be restored'); + }); +}); + +test('1 sync : 1 async unload async side', function (assert) { + let findRecordCalls = 0; + + env.adapter.findRecord = (store, type, id) => { + assert.equal(type, Person, 'findRecord(_, type) is correct'); + assert.equal(id, '1', 'findRecord(_, _, id) is correct'); + ++findRecordCalls; + + return { + data: { + id: 1, + type: 'person' + } + }; + }; + + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteBook: { + data: { + id: 2, + type: 'book' + } + } + } + }, + included: [{ + id: 2, + type: 'book' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let book = env.store.peekRecord('book', 2); + + return run(() => + book.get('person').then(() => { + assert.equal(person.get('favoriteBook.id'), 2, 'initially relationship established lhs'); + assert.equal(book.belongsTo('person').id(), 1, 'initially relationship established rhs'); + + run(() => person.unloadRecord()); + + assert.equal(book.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + return book.get('person'); + }).then((refetchedPerson) => { + assert.notEqual(person, refetchedPerson, 'the previously loaded record is not reused'); + + assert.equal(book.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(refetchedPerson.get('favoriteBook.id'), '2', 'unload async is not treated as delete'); + assert.equal(findRecordCalls, 1); + }) + ); +}); + +test('1 async : many sync unload sync side', function (assert) { + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteSpoons: { + data: [{ + id: 2, + type: 'spoon' + }, { + id: 3, + type: 'spoon' + }] + } + } + }, + included: [{ + id: 2, + type: 'spoon' + }, { + id: 3, + type: 'spoon' + }] + }) + ); + + let person = env.store.peekRecord('person', 1); + let spoon2 = env.store.peekRecord('spoon', 2); + let spoon3 = env.store.peekRecord('spoon', 3); + let spoons = person.get('favoriteSpoons'); + + assert.equal(spoons.isDestroyed, false, 'ManyArray not destroyed'); + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['2', '3'], 'initialy relationship established lhs'); + assert.equal(spoon2.belongsTo('person').id(), '1', 'initially relationship established rhs'); + assert.equal(spoon3.belongsTo('person').id(), '1', 'initially relationship established rhs'); + + run(() => spoon2.unloadRecord()); + + assert.equal(env.store.hasRecordForId('spoon', 2), false, 'unloaded record gone from store'); + + assert.equal(spoons.isDestroyed, false, 'ManyArray not destroyed'); + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['3'], 'unload sync relationship acts as delete'); + assert.equal(spoon3.belongsTo('person').id(), '1', 'unloading one of a sync hasMany does not affect the rest'); + + spoon2 = run(() => + env.store.push({ + data: { + id: 2, + type: 'spoon' + } + }) + ); + + assert.equal(env.store.hasRecordForId('spoon', 2), true, 'unloaded record can be restored'); + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['3'], 'restoring unloaded record does not restore relationship'); + assert.equal(spoon2.belongsTo('person').id(), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteSpoons: { + data: [{ + id: 2, + type: 'spoon' + }, { + id: 3, + type: 'spoon' + }] + } + } + } + }) + ); + + assert.equal(spoon2.belongsTo('person').id(), '1', 'after unloading, relationship can be restored'); + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['2', '3'], 'after unloading, relationship can be restored'); +}); + +test('1 async : many sync unload async side', function (assert) { + let findRecordCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findRecord = (store, type, id) => { + assert.equal(type, Person, 'findRecord(_, type) is correct'); + assert.deepEqual(id, '1', 'findRecord(_, _, id) is correct'); + ++findRecordCalls; + + return { + data: { + id: 1, + type: 'person' + } + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteSpoons: { + data: [ + { + id: 2, + type: 'spoon' + }, + { + id: 3, + type: 'spoon' + } + ] + } + } + }, + included: [ + { + id: 2, + type: 'spoon' + }, + { + id: 3, + type: 'spoon' + } + ] + }) + ); + let spoon2 = env.store.peekRecord('spoon', 2); + let spoon3 = env.store.peekRecord('spoon', 3); + let spoons = person.get('favoriteSpoons'); + + return run(() => { + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['2', '3'], 'initially relationship established lhs'); + assert.equal(spoon2.belongsTo('person').id(), '1', 'initially relationship established rhs'); + assert.equal(spoon3.belongsTo('person').id(), '1', 'initially relationship established rhs'); + + assert.equal(spoons.isDestroyed, false, 'ManyArray is not destroyed'); + + run(() => person.unloadRecord()); + + assert.equal(spoons.isDestroyed, false, 'ManyArray is not destroyed when 1 side is unloaded'); + assert.equal(spoon2.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(spoon3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + return spoon2.get('person'); + }).then((refetchedPerson) => { + assert.notEqual(person, refetchedPerson, 'the previously loaded record is not reused'); + + assert.deepEqual(person.get('favoriteSpoons').mapBy('id'), ['2', '3'], 'unload async is not treated as delete'); + assert.equal(spoon2.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + assert.equal(spoon3.belongsTo('person').id(), '1', 'unload async is not treated as delete'); + + assert.equal(findRecordCalls, 1, 'findRecord called as expected'); + }); +}); + +test('1 sync : many async unload async side', function(assert) { + let findManyCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findMany = (store, type, ids) => { + assert.equal(type+'', Show+'', 'findMany(_, type) is correct'); + assert.deepEqual(ids, ['2', '3'], 'findMany(_, _, ids) is correct'); + ++findManyCalls; + + return { + data: [{ + id: 2, + type: 'show' + }, { + id: 3, + type: 'show' + }] + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteShows: { + data: [ + { + id: 2, + type: 'show' + }, + { + id: 3, + type: 'show' + } + ] + } + } + } + }) + ); + + let shows, show2, show3; + + return run(() => person.get('favoriteShows') + .then((asyncRecords) => { + shows = asyncRecords; + [show2, show3] = shows.toArray(); + + assert.deepEqual(person.hasMany('favoriteShows').ids(), ['2', '3'], 'initially relationship established lhs'); + assert.equal(show2.get('person.id'), '1', 'initially relationship established rhs'); + assert.equal(show3.get('person.id'), '1', 'initially relationship established rhs'); + assert.deepEqual(shows.mapBy('id'), ['2', '3'], 'many array is initially set up correctly'); + + run(() => show2.unloadRecord()); + + assert.deepEqual(shows.mapBy('id'), ['3'], 'unload async removes from previous many array'); + assert.equal(shows.isDestroyed, false, 'previous many array not destroyed'); + + run(() => show3.unloadRecord()); + + assert.deepEqual(shows.mapBy('id'), [], 'unload async removes from previous many array'); + assert.equal(shows.isDestroyed, false, 'previous many array not destroyed'); + assert.deepEqual(person.hasMany('favoriteShows').ids(), ['2', '3'], 'unload async is not treated as delete'); + + return person.get('favoriteShows'); + }).then((refetchedShows) => { + assert.equal(shows.isDestroyed, false, 'previous ManyArray is not immediately destroyed after refetch'); + assert.equal(shows.isDestroying, true, 'previous ManyArray is being destroyed immediately after refetch'); + assert.deepEqual(refetchedShows.mapBy('id'), ['2', '3'], 'shows refetched'); + assert.deepEqual(person.hasMany('favoriteShows').ids(), ['2', '3'], 'unload async is not treated as delete'); + + assert.equal(findManyCalls, 2, 'findMany called as expected'); + }) + ).then(() => { + assert.equal(shows.isDestroyed, true, 'previous ManyArray is destroyed in the runloop after refetching'); + }); +}); + +test('1 sync : many async unload sync side', function(assert) { + let findManyCalls = 0; + + env.adapter.coalesceFindRequests = true; + + env.adapter.findMany = (store, type, ids) => { + assert.equal(type+'', Show+'', 'findMany(_, type) is correct'); + assert.deepEqual(ids, ['2', '3'], 'findMany(_, _, ids) is correct'); + ++findManyCalls; + + return { + data: [{ + id: 2, + type: 'show' + }, { + id: 3, + type: 'show' + }] + }; + }; + + let person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteShows: { + data: [ + { + id: 2, + type: 'show' + }, + { + id: 3, + type: 'show' + } + ] + } + } + } + }) + ); + + let shows, show2, show3; + + return run(() => person.get('favoriteShows') + .then((asyncRecords) => { + shows = asyncRecords; + [show2, show3] = shows.toArray(); + + assert.deepEqual(person.hasMany('favoriteShows').ids(), ['2', '3'], 'initially relationship established lhs'); + assert.equal(show2.get('person.id'), '1', 'initially relationship established rhs'); + assert.equal(show3.get('person.id'), '1', 'initially relationship established rhs'); + assert.deepEqual(shows.mapBy('id'), ['2', '3'], 'many array is initially set up correctly'); + + run(() => person.unloadRecord()); + + assert.equal(env.store.hasRecordForId('person', 1), false, 'unloaded record gone from store'); + + assert.equal(shows.isDestroyed, true, 'previous manyarray immediately destroyed'); + assert.equal(show2.get('person.id'), null, 'unloading acts as delete for sync relationships'); + assert.equal(show3.get('person.id'), null, 'unloading acts as delete for sync relationships'); + + person = run(() => + env.store.push({ + data: { + id: 1, + type: 'person' + } + }) + ); + + assert.equal(env.store.hasRecordForId('person', 1), true, 'unloaded record can be restored'); + assert.deepEqual(person.hasMany('favoriteShows').ids(), [], 'restoring unloaded record does not restore relationship'); + assert.equal(show2.get('person.id'), null, 'restoring unloaded record does not restore relationship'); + assert.equal(show3.get('person.id'), null, 'restoring unloaded record does not restore relationship'); + + run(() => + env.store.push({ + data: { + id: 1, + type: 'person', + relationships: { + favoriteShows: { + data: [ + { + id: 2, + type: 'show' + }, + { + id: 3, + type: 'show' + } + ] + } + } + } + }) + ); + + assert.deepEqual(person.hasMany('favoriteShows').ids(), ['2', '3'], 'relationship can be restored'); + + return person.get('favoriteShows'); + }).then((refetchedShows) => { + assert.notEqual(refetchedShows, shows, 'ManyArray not reused'); + assert.deepEqual(refetchedShows.mapBy('id'), ['2', '3'], 'unload async not treated as a delete'); + + assert.equal(findManyCalls, 1, 'findMany calls as expected'); + }) + ); +}); diff --git a/tests/integration/relationships/many-to-many-test.js b/tests/integration/relationships/many-to-many-test.js index 82f0746466e..420cb0fb5a1 100644 --- a/tests/integration/relationships/many-to-many-test.js +++ b/tests/integration/relationships/many-to-many-test.js @@ -521,7 +521,7 @@ test("Rollbacking attributes for a created record that has a ManyToMany relation }); }); -test("Deleting a record that has a hasMany relationship removes it from the otherMany array but does not remove the other record from itself - sync", function(assert) { +test("Deleting an unpersisted record via rollbackAttributes that has a hasMany relationship removes it from the otherMany array but does not remove the other record from itself - sync", function(assert) { let account, user; run(() => { account = store.push({