diff --git a/packages/-ember-data/addon/-debug/index.js b/packages/-ember-data/addon/-debug/index.js index 5574feb26c3..5b353ae3d57 100644 --- a/packages/-ember-data/addon/-debug/index.js +++ b/packages/-ember-data/addon/-debug/index.js @@ -24,7 +24,7 @@ export function instrument(method) { @param {InternalModel} addedRecord record which should be added/set for the relationship */ -let assertPolymorphicType; +let assertPolymorphicType = (parentInternalModel, relationshipMeta, addedInternalModel, store) => {}; if (DEBUG) { let checkPolymorphic = function checkPolymorphic(modelClass, addedModelClass) { diff --git a/packages/-ember-data/tests/integration/record-array-test.js b/packages/-ember-data/tests/integration/record-array-test.js index 6bc4c6a1a2a..0420a6602c9 100644 --- a/packages/-ember-data/tests/integration/record-array-test.js +++ b/packages/-ember-data/tests/integration/record-array-test.js @@ -269,6 +269,67 @@ module('unit/record-array - RecordArray', function(hooks) { assert.equal(get(recordArray, 'length'), 0, 'record is removed from the array when it is saved'); }); + test('a loaded record is removed from a record array when it is deleted (remove deleted prior to save)', async function(assert) { + assert.expect(5); + this.owner.register( + 'adapter:application', + Adapter.extend({ + removeDeletedFromRelationshipsPriorToSave: true, + deleteRecord() { + return resolve({ data: null }); + }, + shouldBackgroundReloadRecord() { + return false; + } + }) + ); + + store.push({ + data: [{ + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Dale' + } + }, { + type: 'person', + id: '2', + attributes: { + name: 'Scumbag Katz' + } + }, { + type: 'person', + id: '3', + attributes: { + name: 'Scumbag Bryn' + } + }, { + type: 'tag', + id: '1' + }] + }); + + let scumbag = store.peekRecord('person', 1); + let tag = store.peekRecord('tag', 1); + + tag.get('people').addObject(scumbag); + + assert.equal(get(scumbag, 'tag'), tag, "precond - the scumbag's tag has been set"); + + let people = tag.get('people'); + + assert.equal(get(people, 'length'), 1, 'precond - record array has one item'); + assert.equal(get(people.objectAt(0), 'name'), 'Scumbag Dale', "item at index 0 is record with id 1"); + + await scumbag.deleteRecord(); + + assert.equal(get(people, 'length'), 0, "record is removed from the record array"); + + await scumbag.save(); + + assert.equal(get(people, 'length'), 0, 'record is still removed from the array when it is saved'); + }); + test("a loaded record is not removed from a record array when it is deleted even if the belongsTo side isn't defined", async function(assert) { class Person extends Model { @attr() @@ -376,6 +437,61 @@ module('unit/record-array - RecordArray', function(hooks) { assert.equal(tool.get('person'), scumbag, 'the tool still belongs to the record'); }); + test("a loaded record is not removed from both the record array and from the belongs to, even if the belongsTo side isn't defined (remove deleted prior to save)", async function(assert) { + assert.expect(4); + this.owner.register( + 'adapter:application', + Adapter.extend({ + removeDeletedFromRelationshipsPriorToSave: true, + deleteRecord() { + return Promise.resolve({ data: null }); + } + }) + ); + + store.push({ + data: [ + { + type: 'person', + id: '1', + attributes: { + name: 'Scumbag Tom', + }, + }, + { + type: 'tag', + id: '1', + relationships: { + people: { + data: [{ type: 'person', id: '1' }], + }, + }, + }, + { + type: 'tool', + id: '1', + relationships: { + person: { + data: { type: 'person', id: '1' }, + }, + }, + } + ] + }); + + let scumbag = store.peekRecord('person', 1); + let tag = store.peekRecord('tag', 1); + let tool = store.peekRecord('tool', 1); + + assert.equal(tag.get('people.length'), 1, 'person is in the record array'); + assert.equal(tool.get('person'), scumbag, 'the tool belongs to the person'); + + scumbag.deleteRecord(); + + assert.equal(tag.get('people.length'), 0, 'person is not in the record array'); + assert.equal(tool.get('person'), null, 'the tool does not belong to the person'); + }); + // GitHub Issue #168 test('a newly created record is removed from a record array when it is deleted', async function(assert) { let recordArray = store.peekAll('person'); diff --git a/packages/-ember-data/tests/integration/record-data/record-data-test.ts b/packages/-ember-data/tests/integration/record-data/record-data-test.ts index 65617baaee1..28cff08c7d7 100644 --- a/packages/-ember-data/tests/integration/record-data/record-data-test.ts +++ b/packages/-ember-data/tests/integration/record-data/record-data-test.ts @@ -74,6 +74,9 @@ class TestRecordData { isAttrDirty(key: string) { return false; } + isRelationshipDirty(key: string) { + return false; + } removeFromInverseRelationships(isNew: boolean) {} _initRecordCreateOptions(options) {} diff --git a/packages/-ember-data/tests/integration/relationships/belongs-to-test.js b/packages/-ember-data/tests/integration/relationships/belongs-to-test.js index 0cf5dffa416..e33eac7cae3 100644 --- a/packages/-ember-data/tests/integration/relationships/belongs-to-test.js +++ b/packages/-ember-data/tests/integration/relationships/belongs-to-test.js @@ -1120,6 +1120,51 @@ module('integration/relationship/belongs_to Belongs-To Relationships', function( assert.equal(book.get('author'), author, 'Book has an author after rollback attributes'); }); + test("Rollbacking for a deleted record restores implicit relationship - async (remove deleted prior to save)", function(assert) { + env.adapter.removeDeletedFromRelationshipsPriorToSave = true; + Book.reopen({ + author: DS.belongsTo('author', { async: true }) + }); + var book, author; + run(function() { + book = env.store.push({ + data: { + id: '1', + type: 'book', + attributes: { + name: "Stanley's Amazing Adventures" + }, + relationships: { + author: { + data: { + id: '2', + type: 'author' + } + } + } + } + }); + author = env.store.push({ + data: { + id: '2', + type: 'author', + attributes: { + name: 'Stanley' + } + } + }); + + }); + run(() => { + author.deleteRecord(); + author.rollback(); + book.get('author').then((fetchedAuthor) => { + assert.equal(fetchedAuthor, author, 'Book has an author after rollback'); + }); + }); + env.adapter.removeDeletedFromRelationshipsPriorToSave = false; + }); + testInDebug('Passing a model as type to belongsTo should not work', function(assert) { assert.expect(1); diff --git a/packages/-ember-data/tests/integration/relationships/many-to-many-test.js b/packages/-ember-data/tests/integration/relationships/many-to-many-test.js index a7e5ecc85db..76aa4e1aa18 100644 --- a/packages/-ember-data/tests/integration/relationships/many-to-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/many-to-many-test.js @@ -549,6 +549,99 @@ module('integration/relationships/many_to_many_test - ManyToMany relationships', assert.equal(user.get('accounts.length'), 0, 'Accounts got rolledback correctly'); }); + /* Relationship isDirty Tests */ + + test("Relationship isDirty at correct times when adding back removed values", function (assert) { + let user, topic1, topic2; + run(() => { + user = store.push({ + data: { + type: 'user', + id: 1, + attributes: {name: 'Stanley'}, + relationships: {topics: {data: [{type: 'topic', id: 1}]}} + } + }); + // NOTE SB Pushing topics into store (even with updated values) does not dirty the user relationship + topic1 = store.push({data: {type: 'topic', id: 1, attributes: {title: "This year's EmberFest was great"}}}); + topic2 = store.push({data: {type: 'topic', id: 2, attributes: {title: "Last year's EmberFest was great"}}}); + user.get('topics').then(function (topics) { + const relationship = user._internalModel._recordData._relationships.get('topics'); + assert.equal(relationship.isDirty, false, 'pushing topic1 into store does not dirty relationship'); + topics.removeObject(topic1); + assert.equal(relationship.isDirty, true, 'removing topic1 dirties the relationship'); + topics.addObjects([topic1, topic2]); + assert.equal(relationship.isDirty, true, 'adding topic1 and topic2 keeps the relationship dirty'); + topics.removeObject(topic2); + assert.equal(relationship.isDirty, false, 'removing topic2 make the relationship not dirty again'); + }); + }); + }); + test("Relationship isDirty at correct times when removing values that were added", function (assert) { + let user, topic1, topic2, topic3; + run(() => { + user = store.push({ + data: { + type: 'user', + id: 1, + attributes: {name: 'Stanley'}, + relationships: {topics: {data: [{type: 'topic', id: 1}]}} + } + }); + // NOTE SB Pushing topics into store (even with updated values) does not dirty the user relationship + topic1 = store.push({data: {type: 'topic', id: 1, attributes: {title: "This year's EmberFest was great"}}}); + topic2 = store.push({data: {type: 'topic', id: 2, attributes: {title: "Last year's EmberFest was great"}}}); + user.get('topics').then(function (topics) { + const relationship = user._internalModel._recordData._relationships.get('topics'); + assert.equal(relationship.isDirty, false, 'pushing topic1 into store does not dirty relationship'); + topics.addObject(topic2); + assert.equal(relationship.isDirty, true, 'adding topic2 dirties the relationship'); + topics.removeObjects([topic1, topic2]); + assert.equal(relationship.isDirty, true, 'removing topic1 and topic2 keeps the relationship dirty'); + topics.addObject(topic1); + assert.equal(relationship.isDirty, false, 'adding back topic1 makes relationship not dirty again'); + }); + }); + }); + + /* Rollback Relationships Tests */ + + test("Rollback many-to-many relationships works correctly - async", function (assert) { + let user, topic1, topic2; + run(() => { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { topics: { data: [{ type: 'topic', id: 1 }] } } } }); + topic1 = store.push({ data: { type: 'topic', id: 1, attributes: { title: "This year's EmberFest was great" } } }); + topic2 = store.push({ data: { type: 'topic', id: 2, attributes: { title: "Last year's EmberFest was great" } } }); + topic2.get('users').addObject(user); + }); + run(() => { + topic2.rollback(); + topic1.get('users').then(function (fetchedUsers) { + assert.deepEqual(fetchedUsers.toArray(), [user], 'Users are still there'); + }); + topic2.get('users').then(function (fetchedUsers) { + assert.deepEqual(fetchedUsers.toArray(), [], 'Users are still empty'); + }); + user.get('topics').then(function (fetchedTopics) { + assert.deepEqual(fetchedTopics.toArray(), [topic1], 'Topics are still there'); + }); + }); + }); + + test("Rollback many-to-many relationships works correctly - sync", function (assert) { + let user, account1, account2; + run(() => { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { accounts: { data: [{ type: 'account', id: 1 }] } } } }); + account1 = store.push({ data: { type: 'account', id: 1, attributes: { state: 'lonely' } } }); + account2 = store.push({ data: { type: 'account', id: 2, attributes: { state: 'content' } } }); + account2.get('users').addObject(user); + }); + run(account2, 'rollback'); + assert.deepEqual(user.get('accounts').toArray(), [account1], 'Accounts are still there'); + assert.deepEqual(account1.get('users').toArray(), [user], 'Users are still there'); + assert.deepEqual(account2.get('users').toArray(), [], 'Users are still empty'); + }); + todo( 'Re-loading a removed record should re add it to the relationship when the removed record is the last one in the relationship', function(assert) { diff --git a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js index 57737fe9b9c..91f0cdb5d8f 100644 --- a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js @@ -1540,4 +1540,105 @@ module('integration/relationships/one_to_many_test - OneToMany relationships', f assert.ok(message === messageFromArray, 'Only one message record instance should be created'); }); }); + + /* Rollback from Dirty State */ + + test("Rollback one-to-many relationships when the hasMany side has changed - async", function (assert) { + let user, message1, message2; + run(function () { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + message1 = store.push({ data: { type: 'message', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message2 = store.push({ data: { type: 'message', id: 2, relationships: { user: { data: null } } } }); + message2.set('user', user); + }); + run(() => { + message2.rollback(); + message2.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, null, 'Message does not have the user anymore'); + }); + user.get('messages').then(function (fetchedMessages) { + assert.equal(fetchedMessages.get('length'), 1, 'User does not have the message anymore'); + assert.deepEqual(fetchedMessages.toArray(), [message1], 'User only has the original message'); + }); + }); + }); + + test("Rollback one-to-many relationships when the hasMany side has changed - sync", function (assert) { + let user, account1, account2; + run(function () { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + account1 = store.push({ data: { type: 'account', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + account2 = store.push({ data: { type: 'account', id: 2, relationships: { user: { data: null } } } }); + account2.set('user', user); + }); + run(account2, 'rollback'); + assert.equal(account2.get('user'), null, 'Account does not have the user anymore'); + assert.equal(user.get('accounts.length'), 1, "User does not have the account anymore"); + assert.deepEqual(user.get('accounts').toArray(), [account1], "User only has the original account"); + }); + + test("Rollback one-to-many relationships when the belongsTo side has changed - async", function (assert) { + let user, message1, message2, message3, message4, message5, message6, message7, message8, message9; + run(function () { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + message1 = store.push({ data: { type: 'message', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message2 = store.push({ data: { type: 'message', id: 2, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message3 = store.push({ data: { type: 'message', id: 3, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message4 = store.push({ data: { type: 'message', id: 4, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message5 = store.push({ data: { type: 'message', id: 5, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message6 = store.push({ data: { type: 'message', id: 6, relationships: { user: { data: null } } } }); + message7 = store.push({ data: { type: 'message', id: 7, relationships: { user: { data: null } } } }); + message8 = store.push({ data: { type: 'message', id: 8, relationships: { user: { data: null } } } }); + message9 = store.push({ data: { type: 'message', id: 9, relationships: { user: { data: null } } } }); + user.get('messages').addObject(message8); + user.get('messages').addObject(message6); + user.get('messages').removeObject(message3); + user.get('messages').addObject(message9); + user.get('messages').addObject(message7); + user.get('messages').removeObject(message1); + user.get('messages').removeObject(message5); + user.get('messages').addObject(message3); + }); + run(() => { + [message1,message3,message5,message6,message7,message8,message9].forEach(m => m.rollback()); + message8.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, null, 'Message 8 does not belong to the user'); + }); + message6.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, null, 'Message 6 does not belong to the user'); + }); + message9.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, null, 'Message 9 does not belong to the user'); + }); + message7.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, null, 'Message 7 does not belong to the user'); + }); + message1.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, user, 'Message 1 does belong to the user'); + }); + message5.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, user, 'Message 5 does belong to the user'); + }); + message3.get('user').then(function (fetchedUser) { + assert.equal(fetchedUser, user, 'Message 3 does belong to the user'); + }); + user.get('messages').then(function (fetchedMessages) { + assert.deepEqual(fetchedMessages.toArray(), [message1, message2, message3, message4, message5], 'User still has the original 5 messages'); + }); + }); + }); + + test("Rollback one-to-many relationships when the belongsTo side has changed - sync", function (assert) { + let user, account1, account2; + run(() => { + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + account1 = store.push({ data: { type: 'account', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + account2 = store.push({ data: { type: 'account', id: 2, relationships: { user: { data: null } } } }); + user.get('accounts').pushObject(account2); + }); + run(account2, 'rollback'); + assert.equal(account1.get('user'), user, 'Account 1 still has the user'); + assert.equal(account2.get('user'), null, 'Account 2 still does not have the user'); + assert.deepEqual(user.get('accounts').toArray(), [account1], "User only has the original account"); + }); }); diff --git a/packages/-ember-data/tests/integration/relationships/one-to-one-test.js b/packages/-ember-data/tests/integration/relationships/one-to-one-test.js index d33742dc645..242ad33f9fb 100644 --- a/packages/-ember-data/tests/integration/relationships/one-to-one-test.js +++ b/packages/-ember-data/tests/integration/relationships/one-to-one-test.js @@ -990,4 +990,42 @@ module('integration/relationships/one_to_one_test - OneToOne relationships', fun assert.equal(user.get('job'), null, 'Job got rollbacked correctly'); assert.equal(job.get('user'), null, 'Job does not have user anymore'); }); + + /* Rollback Relationships Tests */ + + test("Rollback one-to-one relationships restores both sides of the relationship - async", function (assert) { + let stanley, bob, jim; + run(() => { + stanley = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { bestFriend: { data: { type: 'user', id: 2 } } } } }); + bob = store.push({ data: { type: 'user', id: 2, name: "Stanley's friend" } }); + jim = store.push({ data: { type: 'user', id: 3, name: "Stanley's other friend" } }); + stanley.set('bestFriend', jim); + }); + run(() => { + stanley.rollback(); + stanley.get('bestFriend').then(function (fetchedUser) { + assert.equal(fetchedUser, bob, "Stanley's bestFriend is still Bob"); + }); + bob.get('bestFriend').then(function (fetchedUser) { + assert.equal(fetchedUser, stanley, "Bob's bestFriend is still Stanley"); + }); + jim.get('bestFriend').then(function (fetchedUser) { + assert.equal(fetchedUser, null, "Jim still has no bestFriend"); + }); + }); + }); + + test("Rollback one-to-one relationships restores both sides of the relationship - sync", function (assert) { + let job, stanley, bob; + run(function () { + job = store.push({ data: { type: 'job', id: 2, attributes: { isGood: true } } }); + stanley = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { job: { data: { type: 'job', id: 2 } } } } }); + bob = store.push({ data: { type: 'user', id: 2, attributes: { name: 'Bob' } } }); + job.set('user', bob); + }); + run(job,'rollback'); + assert.equal(stanley.get('job'), job, 'Stanley still has a job'); + assert.equal(bob.get('job'), null, 'Bob still has no job'); + assert.equal(job.get('user'), stanley, 'The job still belongs to Stanley'); + }); }); diff --git a/packages/-ember-data/tests/unit/model/relationships/rollback-test.js b/packages/-ember-data/tests/unit/model/relationships/rollback-test.js new file mode 100644 index 00000000000..a4b63535fea --- /dev/null +++ b/packages/-ember-data/tests/unit/model/relationships/rollback-test.js @@ -0,0 +1,253 @@ +import setupStore from 'dummy/tests/helpers/store'; +import Ember from 'ember'; + +import {module, test} from 'qunit'; + +import DS from 'ember-data'; + +let env, store, Person, Dog; +const run = Ember.run; + +module("unit/model/relationships/rollback - model.rollback()", { + beforeEach() { + Person = DS.Model.extend({ + firstName: DS.attr(), + lastName: DS.attr(), + dogs: DS.hasMany({ async: true }) + }); + + Dog = DS.Model.extend({ + name: DS.attr(), + owner: DS.belongsTo('person', { async: true }) + }); + + env = setupStore({ person: Person, dog: Dog }); + store = env.store; + } +}); + +test("saved changes to relationships should not roll back to a pre-saved state (from child)", function(assert) { + let person1, person2, dog1, dog2, dog3; + + env.adapter.updateRecord = function(store, type, snapshot) { + return Ember.RSVP.resolve({ data: { type: 'dog', id: 2, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + }; + + run(() => { + store.push({ + data: { + type: 'person', + id: 1, + attributes: { + firstName: "Tom", + lastName: "Dale" + } + } + }); + store.push({ + data: { + type: 'person', + id: 2, + attributes: { + firstName: "John", + lastName: "Doe" + } + } + }); + store.push({ + data: { + type: 'dog', + id: 1, + attributes: { + name: "Fido" + }, + relationships: { + owner: { + data: { + type: 'person', + id: 1 + } + } + } + } + }); + store.push({ + data: { + type: 'dog', + id: 2, + attributes: { + name: "Bear" + }, + relationships: { + owner: { + data: { + type: 'person', + id: 2 + } + } + } + } + }); + store.push({ + data: { + type: 'dog', + id: 3, + attributes: { + name: "Spot" + } + } + }); + person1 = store.peekRecord('person', 1); + person2 = store.peekRecord('person', 2); + dog1 = store.peekRecord('dog', 1); + dog2 = store.peekRecord('dog', 2); + dog3 = store.peekRecord('dog', 3); + person1.get('dogs').addObject(dog2); + }); + + run(() => { + dog2.save().then(() => { + person1.get('dogs').addObject(dog3); + dog2.rollback(); + dog3.rollback(); + person1.get('dogs').then(function (dogs) { + assert.deepEqual(dogs.toArray(), [dog1,dog2]); + }); + person2.get('dogs').then(function (dogs) { + assert.deepEqual(dogs.toArray(), []); + }); + dog1.get('owner').then(function (owner) { + assert.equal(owner, person1); + }); + dog2.get('owner').then(function (owner) { + assert.equal(owner, person1); + }); + }); + }); +}); + +// skip("saved changes to relationships should not roll back to a pre-saved state (from parent)", function(assert) { +// var person1, person2, dog1, dog2, dog3; +// +// env.adapter.updateRecord = function(store, type, snapshot) { +// return Ember.RSVP.resolve({ id: 1, dogs: [1] }); +// }; +// +// run(function() { +// store.push({ +// data: { +// type: 'person', +// id: 1, +// attributes: { +// firstName: "Tom", +// lastName: "Dale" +// }, +// relationships: { +// dogs: { +// data: [{ +// type: 'dog', +// id: 1 +// }] +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'person', +// id: 2, +// attributes: { +// firstName: "John", +// lastName: "Doe" +// }, +// relationships: { +// dogs: { +// data: [{ +// type: 'dog', +// id: 2 +// }] +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 1, +// attributes: { +// name: "Fido" +// }, +// relationships: { +// owner: { +// data: { +// type: 'person', +// id: 1 +// } +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 2, +// attributes: { +// name: "Bear" +// }, +// relationships: { +// owner: { +// data: { +// type: 'person', +// id: 2 +// } +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 3, +// attributes: { +// name: "Spot" +// }, +// relationships: { +// owner: { +// data: null +// } +// } +// } +// }); +// person1 = store.peekRecord('person', 1); +// person2 = store.peekRecord('person', 2); +// dog1 = store.peekRecord('dog', 1); +// dog2 = store.peekRecord('dog', 2); +// dog3 = store.peekRecord('dog', 3); +// +// person1.get('dogs').addObject(dog2); +// }); +// +// run(function() { +// person1.save().then(function () { +// person1.get('dogs').addObject(dog3); +// return Ember.RSVP.all([person1.rollback()]); +// }).then(function () { +// person1.get('dogs').then(function (dogs) { +// assert.deepEqual(dogs.toArray(), [dog1,dog2]); +// }); +// person2.get('dogs').then(function (dogs) { +// assert.deepEqual(dogs.toArray(), []); +// }); +// dog1.get('owner').then(function (owner) { +// assert.equal(owner, person1); +// }).then(function () { +// console.log(person1._internalModel._relationships.get('dogs').manyArray.currentState.map(function (i) { return i.id; })); +// console.log(dog2._internalModel._relationships.get('owner').get('id')); +// console.log(dog3._internalModel._relationships.get('owner').get('id')); +// }); +// dog2.get('owner').then(function (owner) { +// assert.equal(owner, person1); +// }); +// }); +// }); +// }); diff --git a/packages/-ember-data/tests/unit/model/rollback-test.js b/packages/-ember-data/tests/unit/model/rollback-test.js new file mode 100644 index 00000000000..3f392421de2 --- /dev/null +++ b/packages/-ember-data/tests/unit/model/rollback-test.js @@ -0,0 +1,253 @@ +import setupStore from 'dummy/tests/helpers/store'; +import Ember from 'ember'; + +import {module, test} from 'qunit'; + +import DS from 'ember-data'; + +let env, store, Person, Dog; +const run = Ember.run; + +module("unit/model/relationships/rollback - model.rollback()", { + beforeEach() { + Person = DS.Model.extend({ + firstName: DS.attr(), + lastName: DS.attr(), + dogs: DS.hasMany({ async: true }) + }); + + Dog = DS.Model.extend({ + name: DS.attr(), + owner: DS.belongsTo('person', { async: true }) + }); + + env = setupStore({ person: Person, dog: Dog }); + store = env.store; + } +}); + +test("saved changes to relationships should not roll back to a pre-saved state (from child)", function(assert) { + let person1, person2, dog1, dog2, dog3; + + env.adapter.updateRecord = function(store, type, snapshot) { + return Ember.RSVP.resolve({ data: { type: 'dog', id: 2, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + }; + + run(() => { + store.push({ + data: { + type: 'person', + id: 1, + attributes: { + firstName: "Tom", + lastName: "Dale" + } + } + }); + store.push({ + data: { + type: 'person', + id: 2, + attributes: { + firstName: "John", + lastName: "Doe" + } + } + }); + store.push({ + data: { + type: 'dog', + id: 1, + attributes: { + name: "Fido" + }, + relationships: { + owner: { + data: { + type: 'person', + id: 1 + } + } + } + } + }); + store.push({ + data: { + type: 'dog', + id: 2, + attributes: { + name: "Bear" + }, + relationships: { + owner: { + data: { + type: 'person', + id: 2 + } + } + } + } + }); + store.push({ + data: { + type: 'dog', + id: 3, + attributes: { + name: "Spot" + } + } + }); + person1 = store.peekRecord('person', 1); + person2 = store.peekRecord('person', 2); + dog1 = store.peekRecord('dog', 1); + dog2 = store.peekRecord('dog', 2); + dog3 = store.peekRecord('dog', 3); + person1.get('dogs').addObject(dog2); + }); + + run(() => { + dog2.save().then(() => { + person1.get('dogs').addObject(dog3); + dog2.rollback(); + dog3.rollback(); + person1.get('dogs').then(function (dogs) { + assert.deepEqual(dogs.toArray(), [dog1,dog2]); + }); + person2.get('dogs').then(function (dogs) { + assert.deepEqual(dogs.toArray(), []); + }); + dog1.get('owner').then(function (owner) { + assert.equal(owner, person1); + }); + dog2.get('owner').then(function (owner) { + assert.equal(owner, person1); + }); + }); + }); +}); + +// skip("saved changes to relationships should not roll back to a pre-saved state (from parent)", function(assert) { +// var person1, person2, dog1, dog2, dog3; +// +// env.adapter.updateRecord = function(store, type, snapshot) { +// return Ember.RSVP.resolve({ id: 1, dogs: [1] }); +// }; +// +// run(function() { +// store.push({ +// data: { +// type: 'person', +// id: 1, +// attributes: { +// firstName: "Tom", +// lastName: "Dale" +// }, +// relationships: { +// dogs: { +// data: [{ +// type: 'dog', +// id: 1 +// }] +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'person', +// id: 2, +// attributes: { +// firstName: "John", +// lastName: "Doe" +// }, +// relationships: { +// dogs: { +// data: [{ +// type: 'dog', +// id: 2 +// }] +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 1, +// attributes: { +// name: "Fido" +// }, +// relationships: { +// owner: { +// data: { +// type: 'person', +// id: 1 +// } +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 2, +// attributes: { +// name: "Bear" +// }, +// relationships: { +// owner: { +// data: { +// type: 'person', +// id: 2 +// } +// } +// } +// } +// }); +// store.push({ +// data: { +// type: 'dog', +// id: 3, +// attributes: { +// name: "Spot" +// }, +// relationships: { +// owner: { +// data: null +// } +// } +// } +// }); +// person1 = store.peekRecord('person', 1); +// person2 = store.peekRecord('person', 2); +// dog1 = store.peekRecord('dog', 1); +// dog2 = store.peekRecord('dog', 2); +// dog3 = store.peekRecord('dog', 3); +// +// person1.get('dogs').addObject(dog2); +// }); +// +// run(function() { +// person1.save().then(function () { +// person1.get('dogs').addObject(dog3); +// return Ember.RSVP.all([person1.rollback()]); +// }).then(function () { +// person1.get('dogs').then(function (dogs) { +// assert.deepEqual(dogs.toArray(), [dog1,dog2]); +// }); +// person2.get('dogs').then(function (dogs) { +// assert.deepEqual(dogs.toArray(), []); +// }); +// dog1.get('owner').then(function (owner) { +// assert.equal(owner, person1); +// }).then(function () { +// console.log(person1._internalModel._relationships.get('dogs').manyArray.currentState.map(function (i) { return i.id; })); +// console.log(dog2._internalModel._relationships.get('owner').get('id')); +// console.log(dog3._internalModel._relationships.get('owner').get('id')); +// }); +// dog2.get('owner').then(function (owner) { +// assert.equal(owner, person1); +// }); +// }); +// }); +// }); diff --git a/packages/adapter/addon/adapter.js b/packages/adapter/addon/adapter.js index 256dcabaf59..404533d45c7 100644 --- a/packages/adapter/addon/adapter.js +++ b/packages/adapter/addon/adapter.js @@ -4,6 +4,7 @@ import EmberObject from '@ember/object'; +// noinspection JSClosureCompilerSyntax /** An adapter is an object that receives requests from a store and translates them into the appropriate action to take against your @@ -420,6 +421,26 @@ export default EmberObject.extend({ */ deleteRecord: null, + /** + This method is used by the store to determine if the store should + remove deleted records from relationships prior to save. + + If this method returns `true` records will remain part of any + associated relationships after being deleted prior to being saved. + + If this method returns `false` records will be removed from any + associated relationships immediately after being deleted. + + By default this method returns `false`. + + @since 2.15.2 + @property shouldRemoveFromRelationshipsOnDelete + @param {DS.Store} store + @param {DS.Snapshot} snapshot + @return {Boolean} + */ + removeDeletedFromRelationshipsPriorToSave: false, + /** By default the store will try to coalesce all `fetchRecord` calls within the same runloop into as few requests as possible by calling groupRecordsForFindMany and passing it into a findMany call. @@ -665,4 +686,33 @@ export default EmberObject.extend({ shouldBackgroundReloadAll(store, snapshotRecordArray) { return true; }, + + // shouldDirtyAttribute(internalModel, context, value) { + // return value !== context.originalValue; + // }, + // + // shouldDirtyBelongsTo(internalModel, context, value) { + // return value !== context.originalValue; + // }, + // + // shouldDirtyHasMany(internalModel, context, value) { + // let relationshipType = internalModel.type.determineRelationshipType({ + // key: context.key, + // kind: context.kind + // }, internalModel.store); + // + // if (relationshipType === 'manyToNone') { + // if (context.added) { + // return !context.originalValue.has(context.added); + // } + // return context.originalValue.has(context.removed); + // } else if (relationshipType === 'manyToMany') { + // const { canonicalMembers, members } = internalModel._relationships.get(context.key); + // if (canonicalMembers.size !== members.size) { + // return true; + // } + // return !canonicalMembers.list.every(x => members.list.includes(x)); + // } + // return false; + // } }); diff --git a/packages/store/addon/-private/system/many-array.js b/packages/store/addon/-private/system/many-array.js index cc96e8a6bda..81e2aba6b99 100644 --- a/packages/store/addon/-private/system/many-array.js +++ b/packages/store/addon/-private/system/many-array.js @@ -14,6 +14,7 @@ import { _objectIsAlive } from './store/common'; import diffArray from './diff-array'; import recordDataFor from './record-data-for'; +// noinspection JSClosureCompilerSyntax /** A `ManyArray` is a `MutableArray` that represents the contents of a has-many relationship. @@ -190,11 +191,14 @@ export default EmberObject.extend(MutableArray, DeprecatedEvent, { this.set('length', toSet.length); this.currentState = toSet.slice(); this.arrayContentDidChange(diff.firstChangeIndex, diff.removedCount, diff.addedCount); - if (isInitialized && diff.addedCount > 0) { - //notify only on additions + if (isInitialized && (diff.addedCount > 0)) { + //notify only on additions //TODO SB Why?! //TODO only notify if unloaded this.internalModel.manyArrayRecordAdded(this.get('key')); } + // if (isInitialized && diff.removedCount > 0) { + // this.internalModel.manyArrayRecordRemoved(this.get('key')); + // } } }, diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 01b39059bdd..66a01a97073 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -864,13 +864,36 @@ export default class InternalModel { return this._recordData.getAttr(key); } - setDirtyHasMany(key, records) { - assertRecordsPassedToHasMany(records); - return this._recordData.setDirtyHasMany(key, extractRecordDatasFromRecords(records)); + setDirtyHasMany(key, value) { + if (this.isDeleted()) { + throw new EmberError(`Attempted to set '${key}' to '${value}' on the deleted record ${this}`); + } + + assertRecordsPassedToHasMany(value); + + this._recordData.setDirtyHasMany(key, extractRecordDatasFromRecords(value)); + let isDirty = this._recordData.isRelationshipDirty(key); + this.send('didSetProperty', { + name: key, + isDirty: isDirty + }); + + return value; } setDirtyBelongsTo(key, value) { - return this._recordData.setDirtyBelongsTo(key, extractRecordDataFromRecord(value)); + if (this.isDeleted()) { + throw new EmberError(`Attempted to set '${key}' to '${value}' on the deleted record ${this}`); + } + + this._recordData.setDirtyBelongsTo(key, extractRecordDataFromRecord(value)); + let isDirty = this._recordData.isRelationshipDirty(key); + this.send('didSetProperty', { + name: key, + isDirty: isDirty + }); + + return value; } setDirtyAttribute(key, value) { @@ -963,6 +986,38 @@ export default class InternalModel { return this._recordData.changedAttributes(); } + hasChangedRelationships() { + if (this.isLoading() && !this.isReloading) { + // no need to instantiate _recordData in this case + return false; + } + return this._recordData.hasChangedRelationships(); + } + + changedRelationships() { + if (this.isLoading() && !this.isReloading) { + // no need to calculate changed attributes when calling `findRecord` + return {}; + } + return this._recordData.changedRelationships(); + } + + hasChanges() { + if (this.isLoading() && !this.isReloading) { + // no need to instantiate _recordData in this case + return false; + } + return this._recordData.hasChanges(); + } + + changes() { + if (this.isLoading() && !this.isReloading) { + // no need to calculate changed attributes when calling `findRecord` + return {}; + } + return this._recordData.changes(); + } + /* @method adapterWillCommit @private @@ -997,9 +1052,15 @@ export default class InternalModel { return currentState[name](this, context); } - manyArrayRecordAdded(key) { + manyArrayRecordAdded(key, record, idx) { + if (this.hasRecord) { + this._record.notifyHasManyAdded(key, record); + } + } + + manyArrayRecordRemoved(key, record) { if (this.hasRecord) { - this._record.notifyHasManyAdded(key); + this._record.notifyHasManyRemoved(key, record); } } @@ -1062,6 +1123,19 @@ export default class InternalModel { this._recordData.clientDidCreate(); } + rollback() { + let dirtyKeys = this._recordData.rollback(); + if (get(this, 'isError')) { + this.didCleanError(); + } + + this.send('rolledBack'); + + if (this._record && dirtyKeys && dirtyKeys.length > 0) { + this._record._notifyProperties(dirtyKeys); + } + } + rollbackAttributes() { let dirtyKeys = this._recordData.rollbackAttributes(); if (get(this, 'isError')) { @@ -1179,6 +1253,10 @@ export default class InternalModel { triggers.length = 0; } + removeFromInverseRelationships0() { + this._recordData.removeFromInverseRelationships0(); + } + removeFromInverseRelationships(isNew = false) { this._recordData.removeFromInverseRelationships(isNew); } diff --git a/packages/store/addon/-private/system/model/model.js b/packages/store/addon/-private/system/model/model.js index fbe1493c83c..beeb34356d3 100644 --- a/packages/store/addon/-private/system/model/model.js +++ b/packages/store/addon/-private/system/model/model.js @@ -786,6 +786,34 @@ const Model = EmberObject.extend(DeprecatedEvented, { return this._internalModel.changedAttributes(); }, + changedRelationships() { + return this._internalModel.changedRelationships(); + }, + + changes() { + return this._internalModel.changes(); + }, + + /** + If the model `isDirty` this function will discard any unsaved + changes. If the model `isNew` it will be removed from the store. + + Example + + ```javascript + record.get('name'); // 'Untitled Document' + record.set('name', 'Doc 1'); + record.get('name'); // 'Doc 1' + record.rollback(); + record.get('name'); // 'Untitled Document' + ``` + + @method rollback + */ + rollback() { + this._internalModel.rollback(); + }, + /** If the model `hasDirtyAttributes` this function will discard any unsaved changes. If the model `isNew` it will be removed from the store. @@ -1212,6 +1240,13 @@ const Model = EmberObject.extend(DeprecatedEvented, { this.notifyPropertyChange(key); }, + notifyHasManyRemoved(key) { + //We need to notifyPropertyChange in the adding case because we need to make sure + //we fetch the newly added record in case it is unloaded + //TODO(Igor): Consider whether we could do this only if the record state is unloaded + this.notifyPropertyChange(key); + }, + eachAttribute(callback, binding) { this.constructor.eachAttribute(callback, binding); }, diff --git a/packages/store/addon/-private/system/model/record-data.ts b/packages/store/addon/-private/system/model/record-data.ts index a11218b4e75..368f3c70174 100644 --- a/packages/store/addon/-private/system/model/record-data.ts +++ b/packages/store/addon/-private/system/model/record-data.ts @@ -8,7 +8,7 @@ import coerceId from '../coerce-id'; import BelongsToRelationship from '../relationships/state/belongs-to'; import ManyRelationship from '../relationships/state/has-many'; import Relationship from '../relationships/state/relationship'; -import RecordData, { ChangedAttributesHash } from '../../ts-interfaces/record-data'; +import RecordData, { ChangedHash } from '../../ts-interfaces/record-data'; import { JsonApiResource, JsonApiResourceIdentity, @@ -106,6 +106,15 @@ export default class RecordDataDefault implements RelationshipRecordData { return this.__attributes !== null && Object.keys(this.__attributes).length > 0; } + hasChangedRelationships() { + let changes = this._relationships.filter((key, relationship) => relationship.isDirty); + return changes.length > 0; + } + + hasChanges() { + return this.hasChangedAttributes() || this.hasChangedRelationships(); + } + _clearErrors() { if (RECORD_DATA_ERRORS) { if (this._errors) { @@ -260,7 +269,7 @@ export default class RecordDataDefault implements RelationshipRecordData { @method changedAttributes @private */ - changedAttributes(): ChangedAttributesHash { + changedAttributes(): ChangedHash { let oldData = this._data; let currentData = this._attributes; let inFlightData = this._inFlightAttributes; @@ -276,10 +285,68 @@ export default class RecordDataDefault implements RelationshipRecordData { return diffData; } + changedRelationships(): ChangedHash { + let oldData = this._relationships.map((key, relationship) => relationship.canonicalState); + let newData = this._relationships.map((key, relationship) => relationship instanceof BelongsToRelationship ? relationship.inverseRecordData : relationship.currentState); + let diffData = Object.create(null); + let newDataKeys = Object.keys(newData); + + for (let i = 0, length = newDataKeys.length; i < length; i++) { + let key = newDataKeys[i]; + diffData[key] = [oldData[key], newData[key]]; + } + + return diffData; + } + + changes(): ChangedHash { + return assign({}, this.changedAttributes(), this.changedRelationships()); + } + isNew() { return this._isNew; } + rollback() { + let dirtyKeys: string[] = []; + + if (this.hasChangedAttributes()) { + dirtyKeys.concat(Object.keys(this._attributes)); + this._attributes = null; + } + + if (this.isNew()) { + this.removeFromInverseRelationships(true); + this._isDeleted = true; + this._isNew = false; + } else if (this.isDeleted()) {debugger + this.addToInverseRelationships(); + this._isDeleted = false; + } else { + //TODO SB Can we filter on changed only? have to do something different with isNew and isDeleted? + // i.e. if (this.hasChangedRelationships()) { ... } or relationship.isDirty? + this._relationships.forEach((key: string, relationship: Relationship) => { + dirtyKeys.push(key); + relationship.rollback(); + }); + + let implicitRelationships = this._implicitRelationships; + Object.keys(implicitRelationships).forEach((key) => { + if (implicitRelationships[key].isDirty) { + dirtyKeys.push(key); + implicitRelationships[key].rollback(); + } + }); + } + + this._inFlightAttributes = null; + + this._clearErrors(); + this.notifyStateChange(); + + return dirtyKeys; + } + rollbackAttributes() { let dirtyKeys; this._isDeleted = false; @@ -353,19 +420,55 @@ export default class RecordDataDefault implements RelationshipRecordData { // set a new "current state" via ResourceIdentifiers setDirtyHasMany(key, recordDatas) { - let relationship = this._relationships.get(key); + let relationship = this._relationships.get(key) as ManyRelationship; relationship.clear(); relationship.addRecordDatas(recordDatas); } // append to "current state" via RecordDatas - addToHasMany(key, recordDatas, idx) { - this._relationships.get(key).addRecordDatas(recordDatas, idx); + addToHasMany(key, added, idx) { + // HACK SB Not sure how to access this here... + let relationship = this._relationships.get(key) as ManyRelationship; + let relationshipType = relationship.store.modelFor(this.modelName).determineRelationshipType(relationship, relationship.store); + + relationship.addRecordDatas(added, idx); + + // NOTE SB adapter.shouldDirtyHasMany() + if (relationshipType === 'manyToNone') { + relationship.isDirty = !relationship.canonicalMembers.has(added); + } else if (relationshipType === 'manyToMany') { + const { canonicalMembers, members } = relationship; + if (canonicalMembers.size !== members.size) { + relationship.isDirty = true; + } else { + relationship.isDirty = !canonicalMembers.list.every(x => members.list.includes(x)); + } + } else { + relationship.isDirty = false; + } } // remove from "current state" via RecordDatas - removeFromHasMany(key, recordDatas) { - this._relationships.get(key).removeRecordDatas(recordDatas); + removeFromHasMany(key, removed) { + // HACK SB Not sure how to access this here... + let relationship = this._relationships.get(key) as ManyRelationship; + let relationshipType = relationship.store.modelFor(this.modelName).determineRelationshipType(relationship, relationship.store); + + relationship.removeRecordDatas(removed); + + // NOTE SB adapter.shouldDirtyHasMany() + if (relationshipType === 'manyToNone') { + relationship.isDirty = relationship.canonicalMembers.has(removed); + } else if (relationshipType === 'manyToMany') { + const { canonicalMembers, members } = relationship; + if (canonicalMembers.size !== members.size) { + relationship.isDirty = true; + } else { + relationship.isDirty = !canonicalMembers.list.every(x => members.list.includes(x)); + } + } else { + relationship.isDirty = false; + } } commitWasRejected(identifier?, errors?: JsonApiValidationError[]) { @@ -392,26 +495,30 @@ export default class RecordDataDefault implements RelationshipRecordData { } setDirtyBelongsTo(key: string, recordData: RelationshipRecordData) { - (this._relationships.get(key) as BelongsToRelationship).setRecordData(recordData); + let relationship = this._relationships.get(key) as BelongsToRelationship; + + // NOTE SB adapter.shouldDirtyBelongsTo() + relationship.isDirty = recordData !== relationship.canonicalState; + relationship.setRecordData(recordData); } setDirtyAttribute(key: string, value: any) { let originalValue; - // Add the new value to the changed attributes hash - this._attributes[key] = value; - if (key in this._inFlightAttributes) { originalValue = this._inFlightAttributes[key]; } else { originalValue = this._data[key]; } - // If we went back to our original value, we shouldn't keep the attribute around anymore - if (value === originalValue) { + + // NOTE SB adapter.shouldDirtyAttribute() + if (value !== originalValue) { + this._attributes[key] = value; + } else { delete this._attributes[key]; } } - getAttr(key: string): string { + getAttr(key: string): any { if (key in this._attributes) { return this._attributes[key]; } else if (key in this._inFlightAttributes) { @@ -530,6 +637,10 @@ export default class RecordDataDefault implements RelationshipRecordData { return originalValue !== this._attributes[key]; } + isRelationshipDirty(key: string): boolean { + return this._relationships.get(key).isDirty; + } + get _attributes() { if (this.__attributes === null) { this.__attributes = Object.create(null); @@ -665,6 +776,20 @@ export default class RecordDataDefault implements RelationshipRecordData { return createOptions; } + addToInverseRelationships() { + this._relationships.forEach((name: string, rel: Relationship) => rel.addRecordDatasToInverse()); + + let implicitRelationships = this._implicitRelationships; + Object.keys(implicitRelationships).forEach((key) => implicitRelationships[key].addRecordDatasToInverse()); + } + + removeFromInverseRelationships0() { + this._relationships.forEach((name: string, rel: Relationship) => rel.removeRecordDatasFromInverse()); + + let implicitRelationships = this._implicitRelationships; + Object.keys(implicitRelationships).forEach((key) => implicitRelationships[key].removeRecordDatasFromInverse()); + } + /* diff --git a/packages/store/addon/-private/system/model/states.js b/packages/store/addon/-private/system/model/states.js index fc3ebe213f1..b8d8468666e 100644 --- a/packages/store/addon/-private/system/model/states.js +++ b/packages/store/addon/-private/system/model/states.js @@ -240,7 +240,7 @@ const DirtyState = { loadingData() {}, propertyWasReset(internalModel, name) { - if (!internalModel.hasChangedAttributes()) { + if (!internalModel.hasChanges()) { internalModel.send('rolledBack'); } }, @@ -271,7 +271,7 @@ const DirtyState = { }, rollback(internalModel) { - internalModel.rollbackAttributes(); + internalModel.rollback(); internalModel.triggerLater('ready'); }, }, @@ -322,6 +322,9 @@ const DirtyState = { // EVENTS deleteRecord(internalModel) { internalModel.transitionTo('deleted.uncommitted'); + if (internalModel.store.adapterFor(internalModel.modelName).removeDeletedFromRelationshipsPriorToSave) { + internalModel._recordData.removeFromInverseRelationships0(); + } }, didSetProperty(internalModel, context) { @@ -415,6 +418,9 @@ const updatedState = dirtyState({ function createdStateDeleteRecord(internalModel) { internalModel.transitionTo('deleted.saved'); internalModel.send('invokeLifecycleCallbacks'); + if (internalModel.store.adapterFor(internalModel.modelName).removeDeletedFromRelationshipsPriorToSave) { + internalModel._recordData.removeFromInverseRelationships0(); + } } createdState.uncommitted.deleteRecord = createdStateDeleteRecord; @@ -448,6 +454,9 @@ updatedState.inFlight.unloadRecord = assertAgainstUnloadRecord; updatedState.uncommitted.deleteRecord = function(internalModel) { internalModel.transitionTo('deleted.uncommitted'); + if (internalModel.store.adapterFor(internalModel.modelName).removeDeletedFromRelationshipsPriorToSave) { + internalModel._recordData.removeFromInverseRelationships0(); + } }; updatedState.invalid.rolledBack = function(internalModel) { @@ -489,6 +498,9 @@ const RootState = { isEmpty: true, // EVENTS + + didSetProperty() {}, + loadingData(internalModel, promise) { internalModel._promiseProxy = promise; internalModel.transitionTo('loading'); @@ -521,6 +533,9 @@ const RootState = { }, // EVENTS + + didSetProperty() {}, + pushedData(internalModel) { internalModel.transitionTo('loaded.saved'); internalModel.triggerLater('didLoad'); @@ -581,6 +596,9 @@ const RootState = { deleteRecord(internalModel) { internalModel.transitionTo('deleted.uncommitted'); + if (internalModel.store.adapterFor(internalModel.modelName).removeDeletedFromRelationshipsPriorToSave) { + internalModel._recordData.removeFromInverseRelationships0(); + } }, unloadRecord(internalModel) {}, @@ -626,12 +644,14 @@ const RootState = { uncommitted: { // EVENTS + didSetProperty() {}, + willCommit(internalModel) { internalModel.transitionTo('inFlight'); }, rollback(internalModel) { - internalModel.rollbackAttributes(); + internalModel.rollback(); internalModel.triggerLater('ready'); }, @@ -693,9 +713,13 @@ const RootState = { internalModel.triggerLater('didCommit', internalModel); }, + // EVENTS + + didSetProperty() {}, + willCommit() {}, didCommit() {}, - pushedData() {}, + pushedData() {} }, invalid: { diff --git a/packages/store/addon/-private/system/relationships/state/belongs-to.ts b/packages/store/addon/-private/system/relationships/state/belongs-to.ts index 36da212746c..ef63af170d6 100644 --- a/packages/store/addon/-private/system/relationships/state/belongs-to.ts +++ b/packages/store/addon/-private/system/relationships/state/belongs-to.ts @@ -1,7 +1,9 @@ import { assert, inspect } from '@ember/debug'; +import { run } from '@ember/runloop'; import { assertPolymorphicType } from 'ember-data/-debug'; import { isNone } from '@ember/utils'; import Relationship from './relationship'; +import ManyRelationship from "./has-many"; import { RelationshipRecordData } from "../../../ts-interfaces/relationship-record-data"; import { JsonApiBelongsToRelationship, JsonApiResourceIdentity } from "../../../ts-interfaces/record-data-json-api"; import { RelationshipSchema } from "../../../ts-interfaces/record-data-schemas"; @@ -12,7 +14,7 @@ import { RelationshipSchema } from "../../../ts-interfaces/record-data-schemas"; export default class BelongsToRelationship extends Relationship { - inverseRecordData: RelationshipRecordData | null; + inverseRecordData: RelationshipRecordData | null; //TODO SB rename to currentState canonicalState: RelationshipRecordData | null; key: string; @@ -24,7 +26,7 @@ export default class BelongsToRelationship extends Relationship { this.key = relationshipMeta.key; } - setRecordData(recordData: RelationshipRecordData) { + setRecordData(recordData: RelationshipRecordData | null) { if (recordData) { this.addRecordData(recordData); } else if (this.inverseRecordData) { @@ -129,6 +131,13 @@ export default class BelongsToRelationship extends Relationship { this.notifyBelongsToChange(); } + addRecordDataToOwn(recordData: RelationshipRecordData) { + if (this.members.has(recordData)) { return; } + this.inverseRecordData = recordData; + super.addRecordDataToOwn(recordData); + this.notifyBelongsToChange(); + } + removeRecordDataFromOwn(recordData: RelationshipRecordData) { if (!this.members.has(recordData)) { return; @@ -234,4 +243,24 @@ export default class BelongsToRelationship extends Relationship { this.setCanonicalRecordData(recordData); } } + + rollback() { + this.setRecordData(this.canonicalState); + + // TODO MMP Can probably eliminate ManyRelationship.canonicalizeOrder() and maybe somehow + // do this with ManyRelationship.addRecordDataToOwn() & ManyArray._add/removeRecordData? + if (!this.inverseRecordData) { return; } + + let rel; + if (this.inverseKey) { + rel = this.inverseRecordData._relationships.get(this.inverseKey); + } else { + rel = this.inverseRecordData._implicitRelationships[this.inverseKeyForImplicit]; + } + + if (rel instanceof ManyRelationship) { + run.scheduleOnce('actions', rel, rel.canonicalizeOrder); + } + super.rollback(); + } } diff --git a/packages/store/addon/-private/system/relationships/state/create.ts b/packages/store/addon/-private/system/relationships/state/create.ts index 227a2ae4d38..25986a9eaa1 100644 --- a/packages/store/addon/-private/system/relationships/state/create.ts +++ b/packages/store/addon/-private/system/relationships/state/create.ts @@ -40,6 +40,13 @@ export default class Relationships { return !!this.initializedRelationships[key]; } + filter(cb) { + let rels = this.initializedRelationships; + return Object.keys(rels).filter(name => { + return cb(name, rels[name]); + }); + } + forEach(cb) { let rels = this.initializedRelationships; Object.keys(rels).forEach(name => { @@ -47,6 +54,13 @@ export default class Relationships { }); } + map(cb) { + let rels = this.initializedRelationships; + return Object.keys(rels).map(name => { + return cb(name, rels[name]); + }); + } + get(key: string) { let relationships = this.initializedRelationships; let relationship = relationships[key]; diff --git a/packages/store/addon/-private/system/relationships/state/has-many.ts b/packages/store/addon/-private/system/relationships/state/has-many.ts index eac4f86bc08..cc4c7f1f9c0 100755 --- a/packages/store/addon/-private/system/relationships/state/has-many.ts +++ b/packages/store/addon/-private/system/relationships/state/has-many.ts @@ -134,6 +134,22 @@ export default class ManyRelationship extends Relationship { this.notifyHasManyChange(); } + addRecordDataToOwn(recordData: RelationshipRecordData, idx?: number) { + if (this.members.has(recordData)) { return; } + super.addRecordDataToOwn(recordData); + debugger + let index = idx || this.currentState.length; + this.currentState.splice(index, 0, recordData); + this.notifyHasManyChange(); + // let manyArray = this.manyArray; + // if (idx !== undefined) { + // //TODO(Igor) not used currently, fix + // manyArray.currentState.insertAt(idx); + // } else { + // manyArray._addInternalModels([recordData]); + // } + } + //TODO(Igor) idx not used currently, fix removeRecordDataFromOwn(recordData: RelationshipRecordData, idx?: number) { super.removeRecordDataFromOwn(recordData, idx); @@ -151,7 +167,16 @@ export default class ManyRelationship extends Relationship { } notifyRecordRelationshipAdded() { - this.notifyHasManyChange(); + //if (this.manyArray.isLoaded) { + this.notifyHasManyChange(); + //} + } + + notifyRecordRelationshipRemoved(recordData: RelationshipRecordData) { + //if (this.manyArray.isLoaded) { + //this.recordData.notifyHasManyRemoved(this.key, recordData); + this.notifyHasManyChange(); + //} } computeChanges(recordDatas: RelationshipRecordData[] = []) { @@ -288,6 +313,50 @@ export default class ManyRelationship extends Relationship { return !hasEmptyRecords; } + + canonicalizeOrder() { + let canonicalMembers = this.canonicalMembers; + let canonicalState = this.canonicalState; + let currentState = this.currentState; + const length = canonicalState.length; + + for (let i = 0, j= 0; i < length; i++) { + let canonicalModel = canonicalState[i]; + let currentModel = currentState[i]; + + if (canonicalModel === currentModel) { j++; continue; } + if (!canonicalMembers.has(currentModel)) { continue; } + + this.removeRecordData(canonicalModel); + this.addRecordData(canonicalModel, j++); + } + } + + rollback() { + let canonicalMembers = this.canonicalMembers; + let canonicalState = this.canonicalState; + let currentState = this.currentState; + const length = canonicalState.length; + + for (let i = 0; i < length; i++) { + let canonicalModel = canonicalState[i]; + let currentModel = currentState[i]; + + //NOTE SB Can't bail here cause model might be new or deleted and need to be added back... or can we somehow? + //if (canonicalModel === currentModel) { continue; } + + if (!canonicalMembers.has(currentModel)) { + this.removeRecordData(currentModel); + } + + this.removeRecordData(canonicalModel); + this.addRecordData(canonicalModel, i); + } + + this.removeRecordDatas(currentState.slice(canonicalState.length)); + + super.rollback(); + } } function setForArray(array) { diff --git a/packages/store/addon/-private/system/relationships/state/relationship.ts b/packages/store/addon/-private/system/relationships/state/relationship.ts index 9ec08383668..38f3fb3cc48 100644 --- a/packages/store/addon/-private/system/relationships/state/relationship.ts +++ b/packages/store/addon/-private/system/relationships/state/relationship.ts @@ -43,6 +43,7 @@ export default class Relationship { hasFailedLoadAttempt: boolean = false; link?: string | null; willSync?: boolean; + isDirty: boolean; constructor( store: any, @@ -183,6 +184,8 @@ export default class Relationship { // which would tell us slightly more about why the // relationship is stale // this.updatedLink = false; + + this.isDirty = false; } get isNew(): boolean { @@ -435,6 +438,25 @@ export default class Relationship { this.setHasAnyRelationshipData(true); } + addRecordDataToInverse(recordData: RelationshipRecordData) { + let inverseRelationship = relationshipStateFor(recordData, this.inverseKey); + //Need to check for existence, as the record might unloading at the moment + if (inverseRelationship) { + inverseRelationship.addRecordDataToOwn(this.recordData); + } + } + + addRecordDatasToInverse() { + this.members.forEach((recordData) => { + this.addRecordDataToInverse(recordData); + }); + } + + addRecordDataToOwn(recordData: RelationshipRecordData) { + this.members.add(recordData); + //this.recordData.updateRecordArrays(); + } + removeRecordData(recordData: RelationshipRecordData) { if (this.members.has(recordData)) { this.removeRecordDataFromOwn(recordData); @@ -464,8 +486,16 @@ export default class Relationship { } } + removeRecordDatasFromInverse() { + this.members.forEach((recordData) => { + this.removeRecordDataFromInverse(recordData); + }); + } + removeRecordDataFromOwn(recordData: RelationshipRecordData | null, idx?: number) { this.members.delete(recordData); + this.notifyRecordRelationshipRemoved(recordData, idx); + //this.recordData.updateRecordArrays(); } removeCanonicalRecordDataFromInverse(recordData: RelationshipRecordData) { @@ -591,6 +621,8 @@ export default class Relationship { notifyRecordRelationshipAdded(recordData?, idxs?) {} + notifyRecordRelationshipRemoved(recordData?, idxs?) {} + setHasAnyRelationshipData(value: boolean) { this.hasAnyRelationshipData = value; } @@ -694,5 +726,9 @@ export default class Relationship { updateData(payload?, initial?) {} + rollback() { + this.isDirty = false; + } + destroy() {} } diff --git a/packages/store/addon/-private/ts-interfaces/record-data.ts b/packages/store/addon/-private/ts-interfaces/record-data.ts index 7a828d67be3..880f6872384 100644 --- a/packages/store/addon/-private/ts-interfaces/record-data.ts +++ b/packages/store/addon/-private/ts-interfaces/record-data.ts @@ -9,7 +9,7 @@ import { @module @ember-data/store */ -export interface ChangedAttributesHash { +export interface ChangedHash { [key: string]: [string, string]; } @@ -25,8 +25,9 @@ export default interface RecordData { willCommit(): void; commitWasRejected(recordIdentifier?: RecordIdentifier, errors?: JsonApiValidationError[]): void; unloadRecord(): void; + rollback(): string[]; rollbackAttributes(): string[]; - changedAttributes(): ChangedAttributesHash; + changedAttributes(): ChangedHash; hasChangedAttributes(): boolean; setDirtyAttribute(key: string, value: any): void; @@ -44,6 +45,12 @@ export default interface RecordData { // ----- unspecced isAttrDirty(key: string): boolean; + isRelationshipDirty(key: string): boolean; + changedRelationships(): ChangedHash; + hasChangedRelationships(): boolean; + changes(): ChangedHash; + hasChanges(): boolean; + removeFromInverseRelationships0(): void; removeFromInverseRelationships(isNew: boolean): void; hasAttr(key: string): boolean;