From 5423b99646cee7eb63a8d94a47a90c8b2fdb1809 Mon Sep 17 00:00:00 2001 From: Mike M Pestorich Date: Fri, 29 Sep 2017 21:50:45 -0700 Subject: [PATCH] Rollback Relationships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit: 1. Allows one to rollback belongsTo and hasMany relationships. 2. Reintroduces dirtyRecordFor*Change hooks on the adapter that allow    one to customize when a record becomes dirty. 3. Added 'removeDeletedFromRelationshipsPriorToSave' flag to Adapter    that allows one to opt back into the old deleted record from many    array behavior (pre #3539). Known issues: 1. Rolling back a hasMany relationship from the parent side of the    relationship does not work (doing the same from the child side works    fine). See test that is commented out below as well as the discussion    at the end of #2881#issuecomment-204634262    This was previously #2881 and is related to #3698 --- packages/-ember-data/addon/-debug/index.js | 2 +- .../tests/integration/record-array-test.js | 116 ++++++++ .../record-data/record-data-test.ts | 3 + .../relationships/belongs-to-test.js | 45 ++++ .../relationships/many-to-many-test.js | 93 +++++++ .../relationships/one-to-many-test.js | 101 +++++++ .../relationships/one-to-one-test.js | 38 +++ .../unit/model/relationships/rollback-test.js | 253 ++++++++++++++++++ .../tests/unit/model/rollback-test.js | 253 ++++++++++++++++++ packages/adapter/addon/adapter.js | 50 ++++ .../store/addon/-private/system/many-array.js | 8 +- .../-private/system/model/internal-model.ts | 90 ++++++- .../addon/-private/system/model/model.js | 35 +++ .../-private/system/model/record-data.ts | 153 ++++++++++- .../addon/-private/system/model/states.js | 32 ++- .../system/relationships/state/belongs-to.ts | 33 ++- .../system/relationships/state/create.ts | 14 + .../system/relationships/state/has-many.ts | 71 ++++- .../relationships/state/relationship.ts | 36 +++ .../-private/ts-interfaces/record-data.ts | 11 +- 20 files changed, 1405 insertions(+), 32 deletions(-) create mode 100644 packages/-ember-data/tests/unit/model/relationships/rollback-test.js create mode 100644 packages/-ember-data/tests/unit/model/rollback-test.js 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;