Skip to content

Commit

Permalink
[BUGFIX release] relationship [x, y] should not break on x.unloadReco…
Browse files Browse the repository at this point in the history
…rd()

For an async relationship [x, y] with x.unloadRecord(), now adjusts only
the relationship’s currentState, leaving that relationship’s canonical
state alone, ensuring the existing client-side delete semantics are
preserved. But when that relationship is reloaded, the canonicalState
consulted.

For sync relationship [x, y] with x.unloadRecord(), both currentState
and canonical state are updated. This is to mirror the client-side
delete semantics. But since we cannot reload a sync relationship we must
assume this to be the new canonical state and rely on subsequent `push`
or `adapterPayloads` or manual `store.push` to update.

This aims to:

* [FIX] hasMany arrays never contain dematerialized records (so they no longer become broken)
* [FIX] using unloadRecord as a type of client side delete is restored
* [PRESERVE] the garbage collector pass to cleanup orphaned models
* [PRESERVE] second access to a relationship which did contain an unloadRecord to cause a reload

note: if both sides of a relationships are unloaded, the above doesn’t
apply. This is largely just when members of a loaded relationship are
themselves unloaded.

[fixes #4986 #5052 #4987 #4996]
  • Loading branch information
stefanpenner committed Jul 25, 2017
1 parent 7ad209f commit 1ab8a98
Show file tree
Hide file tree
Showing 8 changed files with 183 additions and 78 deletions.
48 changes: 28 additions & 20 deletions addon/-private/system/model/internal-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,14 +431,10 @@ export default class InternalModel {
*/
_directlyRelatedInternalModels() {
let array = [];
this.type.eachRelationship((key, relationship) => {
if (this._relationships.has(key)) {
let relationship = this._relationships.get(key);
let localRelationships = relationship.members.toArray();
let serverRelationships = relationship.canonicalMembers.toArray();

array = array.concat(localRelationships, serverRelationships);
}
this._relationships.forEach((name, rel) => {
let local = rel.members.toArray();
let server = rel.canonicalMembers.toArray();
array = array.concat(local, server);
});
return array;
}
Expand Down Expand Up @@ -492,6 +488,7 @@ export default class InternalModel {
unloadRecord() {
this.send('unloadRecord');
this.dematerializeRecord();

if (this._scheduledDestroy === null) {
this._scheduledDestroy = run.schedule('destroy', this, '_checkForOrphanedInternalModels');
}
Expand All @@ -511,6 +508,7 @@ export default class InternalModel {
if (this.isDestroyed) { return; }

this._cleanupOrphanedInternalModels();

}

_cleanupOrphanedInternalModels() {
Expand All @@ -533,6 +531,9 @@ export default class InternalModel {
assert("Cannot destroy an internalModel while its record is materialized", !this._record || this._record.get('isDestroyed') || this._record.get('isDestroying'));

this.store._internalModelDestroyed(this);

this._relationships.forEach((name, rel) => rel.destroy());

this._isDestroyed = true;
}

Expand Down Expand Up @@ -889,14 +890,10 @@ export default class InternalModel {
@private
*/
removeFromInverseRelationships(isNew = false) {
this.eachRelationship((name) => {
if (this._relationships.has(name)) {
let rel = this._relationships.get(name);

rel.removeCompletelyFromInverse();
if (isNew === true) {
rel.clear();
}
this._relationships.forEach((name, rel) => {
rel.removeCompletelyFromInverse();
if (isNew === true) {
rel.clear();
}
});

Expand All @@ -918,17 +915,28 @@ export default class InternalModel {
and destroys any ManyArrays.
*/
destroyRelationships() {
this.eachRelationship((name, relationship) => {
if (this._relationships.has(name)) {
let rel = this._relationships.get(name);
this._relationships.forEach((name, rel) => {
if (rel._inverseIsAsync()) {
rel.removeInternalModelFromInverse(this);
rel.removeInverseRelationships();
} else {
rel.removeCompletelyFromInverse();
}
});

let implicitRelationships = this._implicitRelationships;
this.__implicitRelationships = null;
Object.keys(implicitRelationships).forEach((key) => {
implicitRelationships[key].removeInverseRelationships();
let rel = implicitRelationships[key];

if (rel._inverseIsAsync()) {
rel.removeInternalModelFromInverse(this);
rel.removeInverseRelationships();
} else {
rel.removeCompletelyFromInverse();
}

rel.destroy();
});
}

Expand Down
7 changes: 7 additions & 0 deletions addon/-private/system/relationships/state/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export default class Relationships {
return !!this.initializedRelationships[key];
}

forEach(cb) {
let rels = this.initializedRelationships;
Object.keys(rels).forEach(name => {
cb(name, rels[name]);
});
}

get(key) {
let relationships = this.initializedRelationships;
let relationship = relationships[key];
Expand Down
14 changes: 14 additions & 0 deletions addon/-private/system/relationships/state/has-many.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,20 @@ export default class ManyRelationship extends Relationship {
this.updateInternalModelsFromAdapter(internalModels);
}
}

destroy() {
super.destroy();
let manyArray = this._manyArray;
if (manyArray) {
manyArray.destroy();
}

let proxy = this.__loadingPromise;

if (proxy) {
proxy.destroy();
}
}
}

function setForArray(array) {
Expand Down
14 changes: 12 additions & 2 deletions addon/-private/system/relationships/state/relationship.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export default class Relationship {
return this.internalModel.modelName;
}

_inverseIsAsync() {
if (!this.inverseKey || !this.inverseInternalModel) {
return false;
}
return this.inverseInternalModel._relationships.get(this.inverseKey).isAsync;
}

removeInverseRelationships() {
if (!this.inverseKey) { return; }

Expand Down Expand Up @@ -174,7 +181,7 @@ export default class Relationship {
let relationship = relationships[this.inverseKeyForImplicit];
if (!relationship) {
relationship = relationships[this.inverseKeyForImplicit] =
new Relationship(this.store, internalModel, this.key, { options: {} });
new Relationship(this.store, internalModel, this.key, { options: { async: this.isAsync } });
}
relationship.addCanonicalInternalModel(this.internalModel);
}
Expand Down Expand Up @@ -215,7 +222,7 @@ 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: {} });
internalModel._implicitRelationships[this.inverseKeyForImplicit] = new Relationship(this.store, internalModel, this.key, { options: { async: this.isAsync } });
}
internalModel._implicitRelationships[this.inverseKeyForImplicit].addInternalModel(this.internalModel);
}
Expand Down Expand Up @@ -453,4 +460,7 @@ export default class Relationship {
}

updateData() {}

destroy() {
}
}
91 changes: 44 additions & 47 deletions tests/integration/records/rematerialize-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,22 +146,47 @@ test("an async has many relationship to an unloaded record can restore that reco
// disable background reloading so we do not re-create the relationship.
env.adapter.shouldBackgroundReloadRecord = () => false;

env.adapter.findRecord = function() {
assert.ok('adapter called');
return Ember.RSVP.Promise.resolve({
data: {
type: 'boat',
id: '1',
attributes: {
name: "Boaty McBoatface"
},
relationships: {
person: {
data: { type: 'person', id: '1' }
}
}
const BOAT_ONE = {
type: 'boat',
id: '1',
attributes: {
name: "Boaty McBoatface"
},
relationships: {
person: {
data: { type: 'person', id: '1' }
}
});
}
};

const BOAT_TWO = {
type: 'boat',
id: '2',
attributes: {
name: 'Some other boat'
},
relationships: {
person: {
data: { type: 'person', id: '1' }
}
}
};

env.adapter.findRecord = function(store, model, param) {
assert.ok('adapter called');

let data;
if (param === '1') {
data = BOAT_ONE;
} else if (param === '1') {
data = BOAT_TWO;
} else {
throw new Error(`404: no such boat with id=${param}`);
}

return {
data
};
}

run(function() {
Expand All @@ -186,29 +211,7 @@ test("an async has many relationship to an unloaded record can restore that reco

run(function() {
env.store.push({
data: [{
type: 'boat',
id: '2',
attributes: {
name: 'Some other boat'
},
relationships: {
person: {
data: { type: 'person', id: '1' }
}
}
}, {
type: 'boat',
id: '1',
attributes: {
name: 'Boaty McBoatface'
},
relationships: {
person: {
data: { type: 'person', id: '1' }
}
}
}]
data: [BOAT_ONE, BOAT_TWO]
});
});

Expand All @@ -220,22 +223,16 @@ test("an async has many relationship to an unloaded record can restore that reco
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(() => {
return adam.get('boats');
});
let boats = run(() => adam.get('boats'));

assert.equal(boats.get('length'), 2, 'Before unloading boats.length is correct');

run(function() {
boaty.unloadRecord();
});
run(() => boaty.unloadRecord());

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(() => {
return rematerializedBoaty = adam.get('boats').objectAt(1);
});
let rematerializedBoaty = run(() => adam.get('boats')).objectAt(0);

assert.equal(adam.get('boats.length'), 2, 'boats.length correct after rematerialization');
assert.equal(rematerializedBoaty.get('id'), '1');
Expand Down
5 changes: 3 additions & 2 deletions tests/integration/relationships/has-many-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,16 +207,17 @@ test("hasMany + canonical vs currentState + unloadRecord", function(assert) {
env.store.peekRecord('user', 6).unloadRecord();
});

assert.deepEqual(contacts.map(c => c.get('id')), ['2','3','4','5','6','7'], `user's contacts should have expected contacts`);
assert.deepEqual(contacts.map(c => c.get('id')), ['3','4','5','7'], `user's contacts should have expected contacts`);
assert.equal(contacts, user.get('contacts'));

run(() => {
contacts.addObject(env.store.createRecord('user', { id: 8 }));
});

assert.deepEqual(contacts.map(c => c.get('id')), ['2','3','4','5','6','7','8'], `user's contacts should have expected contacts`);
assert.deepEqual(contacts.map(c => c.get('id')), ['3','4','5','7','8'], `user's contacts should have expected contacts`);
assert.equal(contacts, user.get('contacts'));
});

test("adapter.findMany only gets unique IDs even if duplicate IDs are present in the hasMany relationship", function(assert) {
assert.expect(2);

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/store-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ test("destroying the store correctly cleans everything up", function(assert) {

assert.equal(personWillDestroy.called.length, 1, 'expected person to have recieved willDestroy once');
assert.equal(carWillDestroy.called.length, 1, 'expected car to recieve willDestroy once');
assert.equal(carsWillDestroy.called.length, 1, 'expected cars to recieve willDestroy once');
assert.equal(carsWillDestroy.called.length, 1, 'expected person.cars to recieve willDestroy once');
assert.equal(adapterPopulatedPeopleWillDestroy.called.length, 1, 'expected adapterPopulatedPeople to recieve willDestroy once');
assert.equal(filterdPeopleWillDestroy.called.length, 1, 'expected filterdPeople.willDestroy to have been called once');
});
Expand Down
Loading

0 comments on commit 1ab8a98

Please sign in to comment.