From ea7dbbbef5d1d7b259611b87b35b219a9510f869 Mon Sep 17 00:00:00 2001 From: Chris Thoburn Date: Tue, 4 May 2021 12:00:08 -0700 Subject: [PATCH] Relationship Refactor (part 2): The graph should coordinate state updates (#7493) * perf: the graph should coordinate state updates * fixes for m3 * fix ff on state * prevent infinite recursion during teardown of hasMany * remove unused feature flag * fix ie11 --- .../relationships/belongs-to-test.js | 3 + .../-ember-data/tests/helpers/accessors.ts | 6 +- .../integration/records/create-record-test.js | 2 - .../integration/records/edit-record-test.js | 12 +- .../tests/integration/records/unload-test.js | 140 +++-- .../relationships/has-many-test.js | 160 +++--- .../inverse-relationships-test.js | 12 +- .../relationships/many-to-many-test.js | 2 +- .../serializers/json-api-serializer-test.js | 36 +- .../tests/integration/snapshot-test.js | 25 +- .../tests/integration/store-test.js | 6 +- .../custom-class-model-test.ts | 18 +- .../model/relationships/belongs-to-test.js | 198 ++++---- .../unit/model/relationships/has-many-test.js | 4 +- packages/-ember-data/tests/unit/utils-test.js | 12 +- packages/model/addon/-private/belongs-to.js | 4 +- packages/model/addon/-private/has-many.js | 4 +- packages/model/addon/-private/model.js | 21 +- .../model/addon/-private/system/many-array.js | 48 +- .../addon/-private/graph/-edge-definition.ts | 4 +- .../addon/-private/graph/-operations.ts | 67 +++ .../addon/-private/graph/-utils.ts | 117 +++++ .../record-data/addon/-private/graph/index.ts | 280 ++++++++-- .../operations/add-to-related-records.ts | 61 +++ .../operations/remove-from-related-records.ts | 54 ++ .../operations/replace-related-record.ts | 115 +++++ .../operations/replace-related-records.ts | 339 +++++++++++++ .../graph/operations/update-relationship.ts | 147 ++++++ packages/record-data/addon/-private/index.ts | 2 +- .../record-data/addon/-private/ordered-set.ts | 148 ------ .../record-data/addon/-private/record-data.ts | 194 +++---- .../relationships/state/belongs-to.ts | 378 +------------- .../-private/relationships/state/has-many.ts | 265 +++++----- .../-private/relationships/state/implicit.ts | 53 ++ .../relationships/state/relationship.ts | 477 ------------------ .../integration/graph/edge-removal/helpers.ts | 23 +- .../integration/graph/edge-removal/setup.ts | 50 +- .../tests/integration/graph/edge-test.ts | 66 ++- packages/store/addon/-debug/index.js | 18 +- .../store/addon/-private/identifiers/cache.ts | 2 +- .../store/addon/-private/system/backburner.js | 6 +- .../store/addon/-private/system/core-store.ts | 84 ++- .../addon/-private/system/fetch-manager.ts | 2 +- .../-private/system/model/internal-model.ts | 68 +-- .../-private/system/model/notify-changes.ts | 8 +- .../-private/system/references/belongs-to.js | 17 +- .../-private/system/references/has-many.js | 21 +- .../addon/-private/system/store/finders.js | 10 +- .../system/store/record-data-store-wrapper.ts | 63 +-- 49 files changed, 2017 insertions(+), 1835 deletions(-) create mode 100644 packages/record-data/addon/-private/graph/-operations.ts create mode 100644 packages/record-data/addon/-private/graph/operations/add-to-related-records.ts create mode 100644 packages/record-data/addon/-private/graph/operations/remove-from-related-records.ts create mode 100644 packages/record-data/addon/-private/graph/operations/replace-related-record.ts create mode 100644 packages/record-data/addon/-private/graph/operations/replace-related-records.ts create mode 100644 packages/record-data/addon/-private/graph/operations/update-relationship.ts delete mode 100644 packages/record-data/addon/-private/ordered-set.ts create mode 100644 packages/record-data/addon/-private/relationships/state/implicit.ts delete mode 100644 packages/record-data/addon/-private/relationships/state/relationship.ts diff --git a/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js b/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js index 8430bb73b9d..b1d38b62c7f 100644 --- a/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js +++ b/packages/-ember-data/tests/acceptance/relationships/belongs-to-test.js @@ -376,6 +376,7 @@ module('async belongs-to rendering tests', function (hooks) { assert.ok(pirate.get('bestHuman') === null, 'precond - pirate has no best human'); assert.ok(bestDog === null, 'precond - Chris has no best dog'); + // locally update chris.set('bestDog', shen); bestDog = await chris.get('bestDog'); await settled(); @@ -385,6 +386,7 @@ module('async belongs-to rendering tests', function (hooks) { assert.ok(pirate.get('bestHuman') === null, 'scene 1 - pirate has no best human'); assert.ok(bestDog === shen, "scene 1 - Shen is Chris's best dog"); + // locally update to a different value chris.set('bestDog', pirate); bestDog = await chris.get('bestDog'); await settled(); @@ -394,6 +396,7 @@ module('async belongs-to rendering tests', function (hooks) { assert.ok(pirate.get('bestHuman') === chris, 'scene 2 - pirate now has Chris as best human'); assert.ok(bestDog === pirate, "scene 2 - Pirate is now Chris's best dog"); + // locally clear the relationship chris.set('bestDog', null); bestDog = await chris.get('bestDog'); await settled(); diff --git a/packages/-ember-data/tests/helpers/accessors.ts b/packages/-ember-data/tests/helpers/accessors.ts index 7d60d6ab95c..34ce0dab200 100644 --- a/packages/-ember-data/tests/helpers/accessors.ts +++ b/packages/-ember-data/tests/helpers/accessors.ts @@ -4,15 +4,15 @@ import { recordIdentifierFor } from '@ember-data/store'; type CoreStore = import('@ember-data/store/-private/system/core-store').default; type BelongsToRelationship = import('@ember-data/record-data/-private').BelongsToRelationship; type ManyRelationship = import('@ember-data/record-data/-private').ManyRelationship; -type Relationship = import('@ember-data/record-data/-private').Relationship; +type ImplicitRelationship = import('@ember-data/record-data/-private').Relationship; type RecordDataStoreWrapper = import('@ember-data/store/-private').RecordDataStoreWrapper; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; -type RelationshipDict = import('@ember-data/store/-private/ts-interfaces/utils').ConfidentDict; +type RelationshipDict = import('@ember-data/store/-private/ts-interfaces/utils').ConfidentDict; export function getRelationshipStateForRecord( record: { store: CoreStore }, propertyName: string -): BelongsToRelationship | ManyRelationship | Relationship { +): BelongsToRelationship | ManyRelationship | ImplicitRelationship { const identifier = recordIdentifierFor(record); return graphFor(record.store._storeWrapper).get(identifier, propertyName); } diff --git a/packages/-ember-data/tests/integration/records/create-record-test.js b/packages/-ember-data/tests/integration/records/create-record-test.js index 854a0303c6b..a009d294281 100644 --- a/packages/-ember-data/tests/integration/records/create-record-test.js +++ b/packages/-ember-data/tests/integration/records/create-record-test.js @@ -69,9 +69,7 @@ module('Store.createRecord() coverage', function (hooks) { assert.deepEqual(pets, ['Shen'], 'Precondition: Chris has Shen as a pet'); pet.unloadRecord(); - assert.ok(pet.get('owner') === null, 'Shen no longer has an owner'); - // check that the relationship has been dissolved pets = chris .get('pets') diff --git a/packages/-ember-data/tests/integration/records/edit-record-test.js b/packages/-ember-data/tests/integration/records/edit-record-test.js index 78e704c6989..df1a57fb1e3 100644 --- a/packages/-ember-data/tests/integration/records/edit-record-test.js +++ b/packages/-ember-data/tests/integration/records/edit-record-test.js @@ -266,21 +266,21 @@ module('Editing a Record', function (hooks) { assert.deepEqual(pets, ['Shen', 'Rocky'], 'Precondition: Chris has Shen and Rocky as pets'); shen.set('owner', john); - assert.ok(shen.get('owner') === john, 'Precondition: John is the new owner of Shen'); + assert.ok(shen.get('owner') === john, 'After Update: John is the new owner of Shen'); pets = chris.pets.toArray().map((pet) => pet.name); - assert.deepEqual(pets, ['Rocky'], 'Precondition: Chris has Rocky as a pet'); + assert.deepEqual(pets, ['Rocky'], 'After Update: Chris has Rocky as a pet'); pets = john.pets.toArray().map((pet) => pet.name); - assert.deepEqual(pets, ['Shen'], 'Precondition: John has Shen as a pet'); + assert.deepEqual(pets, ['Shen'], 'After Update: John has Shen as a pet'); chris.unloadRecord(); - assert.ok(rocky.get('owner') === null, 'Rocky has no owner'); - assert.ok(shen.get('owner') === john, 'John should still be the owner of Shen'); + assert.ok(rocky.get('owner') === null, 'After Unload: Rocky has no owner'); + assert.ok(shen.get('owner') === john, 'After Unload: John should still be the owner of Shen'); pets = john.pets.toArray().map((pet) => pet.name); - assert.deepEqual(pets, ['Shen'], 'John still has Shen as a pet'); + assert.deepEqual(pets, ['Shen'], 'After Unload: John still has Shen as a pet'); }); }); diff --git a/packages/-ember-data/tests/integration/records/unload-test.js b/packages/-ember-data/tests/integration/records/unload-test.js index 92ae7a48072..1a1e7ca06fc 100644 --- a/packages/-ember-data/tests/integration/records/unload-test.js +++ b/packages/-ember-data/tests/integration/records/unload-test.js @@ -13,8 +13,8 @@ import JSONAPIAdapter from '@ember-data/adapter/json-api'; import JSONAPISerializer from '@ember-data/serializer/json-api'; import { recordDataFor } from '@ember-data/store/-private'; -function idsFromOrderedSet(set) { - return set.list.map((i) => i.id); +function idsFromArr(arr) { + return arr.map((i) => i.id); } const { attr, belongsTo, hasMany, Model } = DS; @@ -429,26 +429,24 @@ module('integration/unload - Unloading Records', function (hooks) { }; } - test('unloadAll(type) does not leave stranded internalModels in relationships (rediscover via store.push)', function (assert) { + test('unloadAll(type) does not leave stranded internalModels in relationships (rediscover via store.push)', async function (assert) { assert.expect(15); - let person = run(() => - store.push({ - data: { - type: 'person', - id: '1', - attributes: { - name: 'Could be Anybody', - }, - relationships: { - boats: { - data: [{ type: 'boat', id: '1' }], - }, + let person = store.push({ + data: { + type: 'person', + id: '1', + attributes: { + name: 'Could be Anybody', + }, + relationships: { + boats: { + data: [{ type: 'boat', id: '1' }], }, }, - included: [makeBoatOneForPersonOne()], - }) - ); + }, + included: [makeBoatOneForPersonOne()], + }); let boat = store.peekRecord('boat', '1'); let initialBoatInternalModel = boat._internalModel; @@ -463,10 +461,10 @@ module('integration/unload - Unloading Records', function (hooks) { assert.true(store.hasRecordForId('boat', '1')); // ensure the relationship was established (we reach through the async proxy here) - let peopleBoats = run(() => person.get('boats.content')); - let boatPerson = run(() => boat.get('person.content')); + let peopleBoats = await person.get('boats'); + let boatPerson = await boat.get('person'); - assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should be 1'); + assert.equal(relationshipState.canonicalState.length, 1, 'canonical member size should be 1'); assert.equal(relationshipState.members.size, 1, 'members size should be 1'); assert.ok(get(peopleBoats, 'length') === 1, 'Our person has a boat'); assert.ok(peopleBoats.objectAt(0) === boat, 'Our person has the right boat'); @@ -479,7 +477,7 @@ module('integration/unload - Unloading Records', function (hooks) { // ensure that our new state is correct assert.equal(knownPeople.models.length, 1, 'one person record is loaded'); assert.equal(knownBoats.models.length, 0, 'no boat records are loaded'); - assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should still be 1'); + assert.equal(relationshipState.canonicalState.length, 1, 'canonical member size should still be 1'); assert.equal(relationshipState.members.size, 1, 'members size should still be 1'); assert.ok(get(peopleBoats, 'length') === 0, 'Our person thinks they have no boats'); @@ -543,7 +541,7 @@ module('integration/unload - Unloading Records', function (hooks) { let peopleBoats = run(() => person.get('boats.content')); let boatPerson = run(() => boat.get('person.content')); - assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should be 1'); + assert.equal(relationshipState.canonicalState.length, 1, 'canonical member size should be 1'); assert.equal(relationshipState.members.size, 1, 'members size should be 1'); assert.ok(get(peopleBoats, 'length') === 1, 'Our person has a boat'); assert.ok(peopleBoats.objectAt(0) === boat, 'Our person has the right boat'); @@ -556,7 +554,7 @@ module('integration/unload - Unloading Records', function (hooks) { // ensure that our new state is correct assert.equal(knownPeople.models.length, 1, 'one person record is loaded'); assert.equal(knownBoats.models.length, 0, 'no boat records are loaded'); - assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should still be 1'); + assert.equal(relationshipState.canonicalState.length, 1, 'canonical member size should still be 1'); assert.equal(relationshipState.members.size, 1, 'members size should still be 1'); assert.ok(get(peopleBoats, 'length') === 0, 'Our person thinks they have no boats'); @@ -614,8 +612,8 @@ module('integration/unload - Unloading Records', function (hooks) { let peopleBoats = run(() => person.get('boats.content')); let boatPerson = run(() => boat.get('person.content')); - assert.deepEqual(idsFromOrderedSet(relationshipState.canonicalMembers), ['1'], 'canonical member size should be 1'); - assert.deepEqual(idsFromOrderedSet(relationshipState.members), ['1'], 'members size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.canonicalState), ['1'], 'canonical member size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.currentState), ['1'], 'members size should be 1'); assert.ok(get(peopleBoats, 'length') === 1, 'Our person has a boat'); assert.ok(peopleBoats.objectAt(0) === boat, 'Our person has the right boat'); assert.ok(boatPerson === person, 'Our boat has the right person'); @@ -635,12 +633,8 @@ module('integration/unload - Unloading Records', function (hooks) { ); assert.ok(knownBoats.models[0] === initialBoatInternalModel, 'We still have our boat'); assert.true(initialBoatInternalModel.currentState.isEmpty, 'Model is in the empty state'); - assert.deepEqual( - idsFromOrderedSet(relationshipState.canonicalMembers), - ['1'], - 'canonical member size should still be 1' - ); - assert.deepEqual(idsFromOrderedSet(relationshipState.members), ['1'], 'members size should still be 1'); + assert.deepEqual(idsFromArr(relationshipState.canonicalState), ['1'], 'canonical member size should still be 1'); + assert.deepEqual(idsFromArr(relationshipState.currentState), ['1'], 'members size should still be 1'); assert.ok(get(peopleBoats, 'length') === 0, 'Our person thinks they have no boats'); run(() => @@ -652,8 +646,8 @@ module('integration/unload - Unloading Records', function (hooks) { let reloadedBoat = store.peekRecord('boat', '1'); let reloadedBoatInternalModel = reloadedBoat._internalModel; - assert.deepEqual(idsFromOrderedSet(relationshipState.canonicalMembers), ['1'], 'canonical member size should be 1'); - assert.deepEqual(idsFromOrderedSet(relationshipState.members), ['1'], 'members size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.canonicalState), ['1'], 'canonical member size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.currentState), ['1'], 'members size should be 1'); assert.ok( reloadedBoatInternalModel === initialBoatInternalModel, 'after an unloadRecord, subsequent fetch results in the same InternalModel' @@ -673,8 +667,8 @@ module('integration/unload - Unloading Records', function (hooks) { let yaBoat = store.peekRecord('boat', '1'); let yaBoatInternalModel = yaBoat._internalModel; - assert.deepEqual(idsFromOrderedSet(relationshipState.canonicalMembers), ['1'], 'canonical member size should be 1'); - assert.deepEqual(idsFromOrderedSet(relationshipState.members), ['1'], 'members size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.canonicalState), ['1'], 'canonical member size should be 1'); + assert.deepEqual(idsFromArr(relationshipState.currentState), ['1'], 'members size should be 1'); assert.ok( yaBoatInternalModel === initialBoatInternalModel, 'after an unloadRecord, subsequent same-loop push results in the same InternalModel' @@ -1837,48 +1831,46 @@ module('integration/unload - Unloading Records', function (hooks) { }; }; - let [person1, person2] = run(() => - store.push({ - data: [ - { - id: 1, - type: 'person', - relationships: { - friends: { - data: [ - { - id: 3, - type: 'person', - }, - { - id: 4, - type: 'person', - }, - ], - }, + let [person1, person2] = store.push({ + data: [ + { + id: 1, + type: 'person', + relationships: { + friends: { + data: [ + { + id: 3, + type: 'person', + }, + { + id: 4, + type: 'person', + }, + ], }, }, - { - id: 2, - type: 'person', - relationships: { - friends: { - data: [ - { - id: 3, - type: 'person', - }, - { - id: 4, - type: 'person', - }, - ], - }, + }, + { + id: 2, + type: 'person', + relationships: { + friends: { + data: [ + { + id: 3, + type: 'person', + }, + { + id: 4, + type: 'person', + }, + ], }, }, - ], - }) - ); + }, + ], + }); let person1Friends, person3, person4; diff --git a/packages/-ember-data/tests/integration/relationships/has-many-test.js b/packages/-ember-data/tests/integration/relationships/has-many-test.js index 5e36bdfffee..30cbdb4677a 100644 --- a/packages/-ember-data/tests/integration/relationships/has-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/has-many-test.js @@ -1,6 +1,5 @@ /*eslint no-unused-vars: ["error", { "args": "none", "varsIgnorePattern": "(page)" }]*/ -import { A } from '@ember/array'; import { get } from '@ember/object'; import { run } from '@ember/runloop'; @@ -179,7 +178,7 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }); }); - test('hasMany + canonical vs currentState + destroyRecord ', function (assert) { + test('hasMany + canonical vs currentState + destroyRecord ', async function (assert) { assert.expect(7); let store = this.owner.lookup('service:store'); @@ -195,15 +194,15 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( data: [ { type: 'user', - id: 2, + id: '2', }, { type: 'user', - id: 3, + id: '3', }, { type: 'user', - id: 4, + id: '4', }, ], }, @@ -216,15 +215,15 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( included: [ { type: 'user', - id: 2, + id: '2', }, { type: 'user', - id: 3, + id: '3', }, { type: 'user', - id: 4, + id: '4', }, ], }); @@ -255,10 +254,8 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( 'user should have expected contacts' ); - run(() => { - store.peekRecord('user', 2).destroyRecord(); - store.peekRecord('user', 6).destroyRecord(); - }); + await store.peekRecord('user', 2).destroyRecord(); + await store.peekRecord('user', 6).destroyRecord(); assert.deepEqual( contacts.map((c) => c.get('id')), @@ -1923,7 +1920,7 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( } ); - test('A record can be removed from a polymorphic association', function (assert) { + test('A record can be removed from a polymorphic association', async function (assert) { assert.expect(4); let store = this.owner.lookup('service:store'); @@ -1931,9 +1928,9 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( adapter.shouldBackgroundReloadRecord = () => false; - run(function () { - store.push({ - data: { + const [user, comment] = store.push({ + data: [ + { type: 'user', id: '1', relationships: { @@ -1942,37 +1939,23 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }, }, }, - included: [ - { - type: 'comment', - id: '3', - }, - ], - }); + { + type: 'comment', + id: '3', + attributes: {}, + }, + ], }); - let asyncRecords; - run(function () { - asyncRecords = hash({ - user: store.findRecord('user', 1), - comment: store.findRecord('comment', 3), - }); + const messages = await user.messages; - asyncRecords - .then(function (records) { - records.messages = records.user.get('messages'); - return hash(records); - }) - .then(function (records) { - assert.equal(records.messages.get('length'), 1, 'The user has 1 message'); + assert.equal(messages.get('length'), 1, 'The user has 1 message'); - let removedObject = records.messages.popObject(); + let removedObject = messages.popObject(); - assert.equal(removedObject, records.comment, 'The message is correctly removed'); - assert.equal(records.messages.get('length'), 0, 'The user does not have any messages'); - assert.equal(records.messages.objectAt(0), null, "No messages can't be fetched"); - }); - }); + assert.equal(removedObject, comment, 'The message is correctly removed'); + assert.equal(messages.get('length'), 0, 'The user does not have any messages'); + assert.equal(messages.objectAt(0), null, "Null messages can't be fetched"); }); test('When a record is created on the client, its hasMany arrays should be in a loaded state', function (assert) { @@ -2775,9 +2758,7 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }, /The first argument to hasMany must be a string/); }); - test('Relationship.clear removes all records correctly', function (assert) { - let post; - + test('Relationship.clear removes all records correctly', async function (assert) { let store = this.owner.lookup('service:store'); store.modelFor('comment').reopen({ @@ -2788,61 +2769,60 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( comments: hasMany('comment', { inverse: 'post', async: false }), }); - run(() => { - store.push({ - data: [ - { - type: 'post', - id: '2', - attributes: { - title: 'Sailing the Seven Seas', - }, - relationships: { - comments: { - data: [ - { type: 'comment', id: '1' }, - { type: 'comment', id: '2' }, - ], - }, + const [post] = store.push({ + data: [ + { + type: 'post', + id: '2', + attributes: { + title: 'Sailing the Seven Seas', + }, + relationships: { + comments: { + data: [ + { type: 'comment', id: '1' }, + { type: 'comment', id: '2' }, + ], }, }, - { - type: 'comment', - id: '1', - relationships: { - post: { - data: { type: 'post', id: '2' }, - }, + }, + { + type: 'comment', + id: '1', + relationships: { + post: { + data: { type: 'post', id: '2' }, }, }, - { - type: 'comment', - id: '2', - relationships: { - post: { - data: { type: 'post', id: '2' }, - }, + }, + { + type: 'comment', + id: '2', + relationships: { + post: { + data: { type: 'post', id: '2' }, }, }, - { - type: 'comment', - id: '3', - relationships: { - post: { - data: { type: 'post', id: '2' }, - }, + }, + { + type: 'comment', + id: '3', + relationships: { + post: { + data: { type: 'post', id: '2' }, }, }, - ], - }); - post = store.peekRecord('post', 2); + }, + ], }); - run(() => { - getRelationshipStateForRecord(post, 'comments').clear(); - let comments = A(store.peekAll('comment')); - assert.deepEqual(comments.mapBy('post'), [null, null, null]); - }); + const comments = store.peekAll('comment'); + assert.deepEqual(comments.mapBy('post.id'), ['2', '2', '2']); + + const postComments = await post.comments; + postComments.clear(); + + assert.deepEqual(comments.mapBy('post'), [null, null, null]); }); test('unloading a record with associated records does not prevent the store from tearing down', function (assert) { diff --git a/packages/-ember-data/tests/integration/relationships/inverse-relationships-test.js b/packages/-ember-data/tests/integration/relationships/inverse-relationships-test.js index 6cbb5fefd86..305cc647f94 100644 --- a/packages/-ember-data/tests/integration/relationships/inverse-relationships-test.js +++ b/packages/-ember-data/tests/integration/relationships/inverse-relationships-test.js @@ -223,15 +223,15 @@ module('integration/relationships/inverse_relationships - Inverse Relationships' comment.set('post', post); - assert.equal(comment.get('post'), post); - assert.equal(post.get('bestComment'), comment); - assert.strictEqual(post2.get('bestComment'), null); + assert.ok(comment.get('post') === post, 'comment post is set correctly'); + assert.ok(post.get('bestComment') === comment, 'post1 comment is set correctly'); + assert.ok(post2.get('bestComment') === null, 'post2 comment is not set'); post2.set('bestComment', comment); - assert.equal(comment.get('post'), post2); - assert.strictEqual(post.get('bestComment'), null); - assert.equal(post2.get('bestComment'), comment); + assert.true(comment.get('post') === post2, 'comment post is set correctly'); + assert.true(post.get('bestComment') === null, 'post1 comment is no longer set'); + assert.true(post2.get('bestComment') === comment, 'post2 comment is set correctly'); }); test('When setting a belongsTo, the OneToOne invariant is commutative', async function (assert) { 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 7e9496b385d..0fe1f7b6c8d 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 @@ -651,7 +651,7 @@ module('integration/relationships/many_to_many_test - ManyToMany relationships', }); }); - let state = account.hasMany('users').hasManyRelationship.canonicalMembers.list; + let state = account.hasMany('users').hasManyRelationship.canonicalState; let users = account.get('users'); assert.todo.equal(users.get('length'), 1, 'Accounts were updated correctly (ui state)'); diff --git a/packages/-ember-data/tests/integration/serializers/json-api-serializer-test.js b/packages/-ember-data/tests/integration/serializers/json-api-serializer-test.js index c3d88033ee4..c208d7f2100 100644 --- a/packages/-ember-data/tests/integration/serializers/json-api-serializer-test.js +++ b/packages/-ember-data/tests/integration/serializers/json-api-serializer-test.js @@ -442,29 +442,27 @@ module('integration/serializers/json-api-serializer - JSONAPISerializer', functi let store = this.owner.lookup('service:store'); let serializer = store.serializerFor('application'); - run(function () { - serializer.pushPayload(store, { - data: { - type: 'handles', - id: 1, - }, - }); + serializer.pushPayload(store, { + data: { + type: 'handles', + id: 1, + }, + }); - let handle = store.peekRecord('handle', 1); - handle.set('user', null); + let handle = store.peekRecord('handle', 1); + handle.set('user', null); - let serialized = handle.serialize({ includeId: true }); - assert.deepEqual(serialized, { - data: { - type: 'handles', - id: '1', - relationships: { - user: { - data: null, - }, + let serialized = handle.serialize({ includeId: true }); + assert.deepEqual(serialized, { + data: { + type: 'handles', + id: '1', + relationships: { + user: { + data: null, }, }, - }); + }, }); }); diff --git a/packages/-ember-data/tests/integration/snapshot-test.js b/packages/-ember-data/tests/integration/snapshot-test.js index ab5a6d9c8c8..752ab62bbd1 100644 --- a/packages/-ember-data/tests/integration/snapshot-test.js +++ b/packages/-ember-data/tests/integration/snapshot-test.js @@ -588,25 +588,20 @@ module('integration/snapshot - Snapshot', function (hooks) { let post = store.peekRecord('post', 1); let comment = store.peekRecord('comment', 2); - await post.get('comments').then((comments) => { - comments.addObject(comment); + const comments = await post.get('comments'); + comments.addObject(comment); - let postSnapshot = post._createSnapshot(); - let commentSnapshot = comment._createSnapshot(); + let postSnapshot = post._createSnapshot(); + let commentSnapshot = comment._createSnapshot(); - let hasManyRelationship = postSnapshot.hasMany('comments'); - let belongsToRelationship = commentSnapshot.belongsTo('post'); + let hasManyRelationship = postSnapshot.hasMany('comments'); + let belongsToRelationship = commentSnapshot.belongsTo('post'); - assert.ok(hasManyRelationship instanceof Array, 'hasMany relationship is an instance of Array'); - assert.equal(hasManyRelationship.length, 1, 'hasMany relationship contains related object'); + assert.ok(hasManyRelationship instanceof Array, 'hasMany relationship is an instance of Array'); + assert.equal(hasManyRelationship.length, 1, 'hasMany relationship contains related object'); - assert.ok(belongsToRelationship instanceof Snapshot, 'belongsTo relationship is an instance of Snapshot'); - assert.equal( - belongsToRelationship.attr('title'), - 'Hello World', - 'belongsTo relationship contains related object' - ); - }); + assert.ok(belongsToRelationship instanceof Snapshot, 'belongsTo relationship is an instance of Snapshot'); + assert.equal(belongsToRelationship.attr('title'), 'Hello World', 'belongsTo relationship contains related object'); }); test('snapshot.belongsTo() and snapshot.hasMany() returns correctly when setting an object to a belongsTo relationship', function (assert) { diff --git a/packages/-ember-data/tests/integration/store-test.js b/packages/-ember-data/tests/integration/store-test.js index 811661bafb8..8bb14d4ed09 100644 --- a/packages/-ember-data/tests/integration/store-test.js +++ b/packages/-ember-data/tests/integration/store-test.js @@ -1023,7 +1023,7 @@ module('integration/store - deleteRecord', function (hooks) { setupTest(hooks); test('Using store#deleteRecord should mark the model for removal', function (assert) { - assert.expect(3); + assert.expect(2); this.owner.register('model:person', Person); this.owner.register('adapter:application', RESTAdapter.extend()); @@ -1046,11 +1046,7 @@ module('integration/store - deleteRecord', function (hooks) { assert.ok(store.hasRecordForId('person', '1'), 'expected the record to be in the store'); - let personDeleteRecord = tap(person, 'deleteRecord'); - store.deleteRecord(person); - - assert.equal(personDeleteRecord.called.length, 1, 'expected person.deleteRecord to have been called'); assert.ok(person.isDeleted, 'expect person to be isDeleted'); }); diff --git a/packages/-ember-data/tests/unit/custom-class-support/custom-class-model-test.ts b/packages/-ember-data/tests/unit/custom-class-support/custom-class-model-test.ts index d49e8be6524..683a918bf78 100644 --- a/packages/-ember-data/tests/unit/custom-class-support/custom-class-model-test.ts +++ b/packages/-ember-data/tests/unit/custom-class-support/custom-class-model-test.ts @@ -1,3 +1,5 @@ +import settled from '@ember/test-helpers/settled'; + import { module, test } from 'qunit'; import RSVP from 'rsvp'; @@ -69,8 +71,8 @@ if (CUSTOM_MODEL_CLASS) { owner.unregister('service:store'); }); - test('notification manager', function (assert) { - assert.expect(9); + test('notification manager', async function (assert) { + assert.expect(7); let notificationCount = 0; let identifier; let recordData; @@ -91,13 +93,11 @@ if (CUSTOM_MODEL_CLASS) { notificationCount++; assert.equal(passedId, identifier, 'passed the identifier to the callback'); if (notificationCount === 1) { - assert.equal(key, 'relationships', 'passed the key'); - } else if (notificationCount === 2) { - assert.equal(key, 'relationships', 'passed the key'); - } else if (notificationCount === 3) { assert.equal(key, 'state', 'passed the key'); - } else if (notificationCount === 4) { + } else if (notificationCount === 2) { assert.equal(key, 'errors', 'passed the key'); + } else if (notificationCount === 3) { + assert.equal(key, 'relationships', 'passed the key'); } }); return { hi: 'igor' }; @@ -110,7 +110,9 @@ if (CUSTOM_MODEL_CLASS) { recordData.storeWrapper.notifyBelongsToChange(identifier.type, identifier.id, identifier.lid, 'key'); recordData.storeWrapper.notifyStateChange(identifier.type, identifier.id, identifier.lid, 'key'); recordData.storeWrapper.notifyErrorsChange(identifier.type, identifier.id, identifier.lid, 'key'); - assert.equal(notificationCount, 4, 'called notification callback'); + await settled(); + + assert.equal(notificationCount, 3, 'called notification callback'); }); test('record creation and teardown', function (assert) { diff --git a/packages/-ember-data/tests/unit/model/relationships/belongs-to-test.js b/packages/-ember-data/tests/unit/model/relationships/belongs-to-test.js index fe39eb8c8cf..9df3e8829d1 100644 --- a/packages/-ember-data/tests/unit/model/relationships/belongs-to-test.js +++ b/packages/-ember-data/tests/unit/model/relationships/belongs-to-test.js @@ -8,10 +8,11 @@ import DS from 'ember-data'; import { setupTest } from 'ember-qunit'; import Adapter from '@ember-data/adapter'; +import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; import JSONAPISerializer from '@ember-data/serializer/json-api'; import testInDebug from '@ember-data/unpublished-test-infra/test-support/test-in-debug'; -module('unit/model/relationships - DS.belongsTo', function (hooks) { +module('unit/model/relationships - belongsTo', function (hooks) { setupTest(hooks); hooks.beforeEach(function () { @@ -22,14 +23,14 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('belongsTo lazily loads relationships as needed', function (assert) { assert.expect(5); - const Tag = DS.Model.extend({ - name: DS.attr('string'), - people: DS.hasMany('person', { async: false }), + const Tag = Model.extend({ + name: attr('string'), + people: hasMany('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: false }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: false }), }); this.owner.register('model:tag', Tag); @@ -100,15 +101,15 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('belongsTo does not notify when it is initially reified', function (assert) { assert.expect(1); - const Tag = DS.Model.extend({ - name: DS.attr('string'), - people: DS.hasMany('person', { async: false }), + const Tag = Model.extend({ + name: attr('string'), + people: hasMany('person', { async: false }), }); Tag.toString = () => 'Tag'; - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: false }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: false }), }); Person.toString = () => 'Person'; @@ -166,13 +167,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('async belongsTo relationships work when the data hash has not been loaded', function (assert) { assert.expect(5); - const Tag = DS.Model.extend({ - name: DS.attr('string'), + const Tag = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: true }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: true }), }); this.owner.register('model:tag', Tag); @@ -220,13 +221,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('async belongsTo relationships are not grouped with coalesceFindRequests=false', async function (assert) { assert.expect(6); - const Tag = DS.Model.extend({ - name: DS.attr('string'), + const Tag = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: true }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: true }), }); this.owner.register('model:tag', Tag); @@ -305,13 +306,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('async belongsTo relationships are grouped with coalesceFindRequests=true', async function (assert) { assert.expect(6); - const Tag = DS.Model.extend({ - name: DS.attr('string'), + const Tag = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: true }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: true }), }); this.owner.register('model:tag', Tag); @@ -388,13 +389,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('async belongsTo relationships work when the data hash has already been loaded', function (assert) { assert.expect(3); - const Tag = DS.Model.extend({ - name: DS.attr('string'), + const Tag = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: true }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: true }), }); this.owner.register('model:tag', Tag); @@ -440,9 +441,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }); }); - test('when response to saving a belongsTo is a success but includes changes that reset the users change', function (assert) { - const Tag = DS.Model.extend(); - const User = DS.Model.extend({ tag: DS.belongsTo() }); + test('when response to saving a belongsTo is a success but includes changes that reset the users change', async function (assert) { + const Tag = Model.extend({ + label: attr(), + }); + const User = Model.extend({ + tag: belongsTo('tag', { async: false, inverse: null }), + }); this.owner.register('model:tag', Tag); this.owner.register('model:user', User); @@ -450,27 +455,29 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); - run(() => { - store.push({ - data: [ - { - type: 'user', - id: '1', - relationships: { - tag: { - data: { type: 'tag', id: '1' }, - }, + const [user, tag1, tag2] = store.push({ + data: [ + { + type: 'user', + id: '1', + relationships: { + tag: { + data: { type: 'tag', id: '1' }, }, }, - { type: 'tag', id: '1' }, - { type: 'tag', id: '2' }, - ], - }); + }, + { type: 'tag', id: '1', attributes: { label: 'A' } }, + { type: 'tag', id: '2', attributes: { label: 'B' } }, + ], }); - let user = store.peekRecord('user', '1'); + assert.equal(tag1.label, 'A', 'tag1 is loaded'); + assert.equal(tag2.label, 'B', 'tag2 is loaded'); + assert.equal(user.tag.id, '1', 'user starts with tag1 as tag'); + + user.set('tag', tag2); - run(() => user.set('tag', store.peekRecord('tag', '2'))); + assert.equal(user.tag.id, '2', 'user tag updated to tag2'); adapter.updateRecord = function () { return { @@ -489,24 +496,21 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }; }; - return run(() => { - return user.save().then((user) => { - assert.equal(user.get('tag.id'), '1', 'expected new server state to be applied'); - }); - }); + await user.save(); + assert.equal(user.tag.id, '1', 'expected new server state to be applied'); }); test('calling createRecord and passing in an undefined value for a relationship should be treated as if null', function (assert) { assert.expect(1); - const Tag = DS.Model.extend({ - name: DS.attr('string'), - person: DS.belongsTo('person', { async: false }), + const Tag = Model.extend({ + name: attr('string'), + person: belongsTo('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: false }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: false }), }); this.owner.register('model:tag', Tag); @@ -527,14 +531,14 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }); test('When finding a hasMany relationship the inverse belongsTo relationship is available immediately', function (assert) { - const Occupation = DS.Model.extend({ - description: DS.attr('string'), - person: DS.belongsTo('person', { async: false }), + const Occupation = Model.extend({ + description: attr('string'), + person: belongsTo('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - occupations: DS.hasMany('occupation', { async: true }), + const Person = Model.extend({ + name: attr('string'), + occupations: hasMany('occupation', { async: true }), }); this.owner.register('model:occupation', Occupation); @@ -598,14 +602,14 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('When finding a belongsTo relationship the inverse belongsTo relationship is available immediately', function (assert) { assert.expect(1); - const Occupation = DS.Model.extend({ - description: DS.attr('string'), - person: DS.belongsTo('person', { async: false }), + const Occupation = Model.extend({ + description: attr('string'), + person: belongsTo('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - occupation: DS.belongsTo('occupation', { async: true }), + const Person = Model.extend({ + name: attr('string'), + occupation: belongsTo('occupation', { async: true }), }); this.owner.register('model:occupation', Occupation); @@ -642,14 +646,14 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { test('belongsTo supports relationships to models with id 0', function (assert) { assert.expect(5); - const Tag = DS.Model.extend({ - name: DS.attr('string'), - people: DS.hasMany('person', { async: false }), + const Tag = Model.extend({ + name: attr('string'), + people: hasMany('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag', { async: false }), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag', { async: false }), }); this.owner.register('model:tag', Tag); @@ -718,13 +722,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }); testInDebug('belongsTo gives a warning when provided with a serialize option', function (assert) { - const Hobby = DS.Model.extend({ - name: DS.attr('string'), + const Hobby = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - hobby: DS.belongsTo('hobby', { serialize: true, async: true }), + const Person = Model.extend({ + name: attr('string'), + hobby: belongsTo('hobby', { serialize: true, async: true }), }); this.owner.register('model:hobby', Hobby); @@ -778,13 +782,13 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }); testInDebug('belongsTo gives a warning when provided with an embedded option', function (assert) { - const Hobby = DS.Model.extend({ - name: DS.attr('string'), + const Hobby = Model.extend({ + name: attr('string'), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - hobby: DS.belongsTo('hobby', { embedded: true, async: true }), + const Person = Model.extend({ + name: attr('string'), + hobby: belongsTo('hobby', { embedded: true, async: true }), }); this.owner.register('model:hobby', Hobby); @@ -838,14 +842,14 @@ module('unit/model/relationships - DS.belongsTo', function (hooks) { }); test('belongsTo should be async by default', function (assert) { - const Tag = DS.Model.extend({ - name: DS.attr('string'), - people: DS.hasMany('person', { async: false }), + const Tag = Model.extend({ + name: attr('string'), + people: hasMany('person', { async: false }), }); - const Person = DS.Model.extend({ - name: DS.attr('string'), - tag: DS.belongsTo('tag'), + const Person = Model.extend({ + name: attr('string'), + tag: belongsTo('tag'), }); this.owner.register('model:tag', Tag); diff --git a/packages/-ember-data/tests/unit/model/relationships/has-many-test.js b/packages/-ember-data/tests/unit/model/relationships/has-many-test.js index a72fec53675..38d1ff4a101 100644 --- a/packages/-ember-data/tests/unit/model/relationships/has-many-test.js +++ b/packages/-ember-data/tests/unit/model/relationships/has-many-test.js @@ -1394,7 +1394,7 @@ module('unit/model/relationships - DS.hasMany', function (hooks) { }); }); - let hasManyCanonical = person.hasMany('pets').hasManyRelationship.canonicalMembers.list; + let hasManyCanonical = person.hasMany('pets').hasManyRelationship.canonicalState; assert.todo.deepEqual( pets.map((p) => get(p, 'id')), @@ -1515,7 +1515,7 @@ module('unit/model/relationships - DS.hasMany', function (hooks) { }); }); - let hasManyCanonical = person.hasMany('pets').hasManyRelationship.canonicalMembers.list; + let hasManyCanonical = person.hasMany('pets').hasManyRelationship.canonicalState; assert.todo.deepEqual( pets.map((p) => get(p, 'id')), diff --git a/packages/-ember-data/tests/unit/utils-test.js b/packages/-ember-data/tests/unit/utils-test.js index f1f82f25647..ea968c50d81 100644 --- a/packages/-ember-data/tests/unit/utils-test.js +++ b/packages/-ember-data/tests/unit/utils-test.js @@ -70,9 +70,9 @@ module('unit/utils', function (hooks) { }); let relationship = user.relationshipFor('messages'); - user = user._internalModel; - post = post._internalModel; - person = person._internalModel; + user = user._internalModel.identifier; + post = post._internalModel.identifier; + person = person._internalModel.identifier; try { assertPolymorphicType(user, relationship, post, store); @@ -130,9 +130,9 @@ module('unit/utils', function (hooks) { }); let relationship = post.relationshipFor('medias'); - post = post._internalModel; - video = video._internalModel; - person = person._internalModel; + post = post._internalModel.identifier; + video = video._internalModel.identifier; + person = person._internalModel.identifier; try { assertPolymorphicType(post, relationship, video, store); diff --git a/packages/model/addon/-private/belongs-to.js b/packages/model/addon/-private/belongs-to.js index 66b6f37c6f7..68788e906f7 100644 --- a/packages/model/addon/-private/belongs-to.js +++ b/packages/model/addon/-private/belongs-to.js @@ -176,7 +176,9 @@ function belongsTo(modelName, options) { ); } } - this._internalModel.setDirtyBelongsTo(key, value); + this.store._backburner.join(() => { + this._internalModel.setDirtyBelongsTo(key, value); + }); return this._internalModel.getBelongsTo(key); }, diff --git a/packages/model/addon/-private/has-many.js b/packages/model/addon/-private/has-many.js index 9c61011d1ce..8910db5e5db 100644 --- a/packages/model/addon/-private/has-many.js +++ b/packages/model/addon/-private/has-many.js @@ -197,7 +197,9 @@ function hasMany(type, options) { } } let internalModel = this._internalModel; - internalModel.setDirtyHasMany(key, records); + this.store._backburner.join(() => { + internalModel.setDirtyHasMany(key, records); + }); return internalModel.getHasMany(key); }, diff --git a/packages/model/addon/-private/model.js b/packages/model/addon/-private/model.js index b3778372195..ad5decb2ae0 100644 --- a/packages/model/addon/-private/model.js +++ b/packages/model/addon/-private/model.js @@ -6,7 +6,12 @@ import { isNone } from '@ember/utils'; import { DEBUG } from '@glimmer/env'; import Ember from 'ember'; -import { RECORD_DATA_ERRORS, RECORD_DATA_STATE, REQUEST_SERVICE } from '@ember-data/canary-features'; +import { + CUSTOM_MODEL_CLASS, + RECORD_DATA_ERRORS, + RECORD_DATA_STATE, + REQUEST_SERVICE, +} from '@ember-data/canary-features'; import { HAS_DEBUG_PACKAGE } from '@ember-data/private-build-infra'; import { DEPRECATE_EVENTED_API_USAGE, @@ -490,7 +495,7 @@ class Model extends EmberObject { if (REQUEST_SERVICE) { if (isReloading === undefined) { let requests = this.store.getRequestStateService().getPendingRequestsForRecord(recordIdentifierFor(this)); - let value = !!requests.find((req) => req.request.data[0].options.isReloading); + let value = !!requests.filter((req) => req.request.data[0].options.isReloading)[0]; meta.isReloading = value; return value; } @@ -800,7 +805,11 @@ class Model extends EmberObject { @method deleteRecord */ deleteRecord() { - this._internalModel.deleteRecord(); + if (CUSTOM_MODEL_CLASS) { + this.store.deleteRecord(this); + } else { + this._internalModel.deleteRecord(); + } } /** @@ -862,7 +871,11 @@ class Model extends EmberObject { if (this.isDestroyed) { return; } - this._internalModel.unloadRecord(); + if (CUSTOM_MODEL_CLASS) { + this.store.unloadRecord(this); + } else { + this._internalModel.unloadRecord(); + } } /** diff --git a/packages/model/addon/-private/system/many-array.js b/packages/model/addon/-private/system/many-array.js index a7c19465d90..8eeeeb27e7e 100644 --- a/packages/model/addon/-private/system/many-array.js +++ b/packages/model/addon/-private/system/many-array.js @@ -180,12 +180,6 @@ export default EmberObject.extend(MutableArray, DeprecatedEvented, { }, objectAt(index) { - // TODO we likely need to force flush here - /* - if (this.relationship._willUpdateManyArray) { - this.relationship._flushPendingManyArrayUpdates(); - } - */ let internalModel = this.currentState[index]; if (internalModel === undefined) { return; @@ -217,26 +211,28 @@ export default EmberObject.extend(MutableArray, DeprecatedEvented, { }, replace(idx, amt, objects) { - let internalModels; - if (amt > 0) { - internalModels = this.currentState.slice(idx, idx + amt); - this.get('recordData').removeFromHasMany( - this.get('key'), - internalModels.map((im) => recordDataFor(im)) - ); - } - if (objects) { - assert( - 'The third argument to replace needs to be an array.', - Array.isArray(objects) || EmberArray.detect(objects) - ); - this.get('recordData').addToHasMany( - this.get('key'), - objects.map((obj) => recordDataFor(obj)), - idx - ); - } - this.retrieveLatest(); + this.store._backburner.join(() => { + let internalModels; + if (amt > 0) { + internalModels = this.currentState.slice(idx, idx + amt); + this.get('recordData').removeFromHasMany( + this.get('key'), + internalModels.map((im) => recordDataFor(im)) + ); + } + if (objects) { + assert( + 'The third argument to replace needs to be an array.', + Array.isArray(objects) || EmberArray.detect(objects) + ); + this.get('recordData').addToHasMany( + this.get('key'), + objects.map((obj) => recordDataFor(obj)), + idx + ); + } + this.retrieveLatest(); + }); }, // Ok this is kinda funky because if buggy we might lose positions, etc. diff --git a/packages/record-data/addon/-private/graph/-edge-definition.ts b/packages/record-data/addon/-private/graph/-edge-definition.ts index 61016c5dcb4..d46e1c7a13a 100644 --- a/packages/record-data/addon/-private/graph/-edge-definition.ts +++ b/packages/record-data/addon/-private/graph/-edge-definition.ts @@ -19,6 +19,7 @@ export interface UpgradedMeta { isCollection: boolean; isPolymorphic: boolean; + inverseKind: 'hasMany' | 'belongsTo' | 'implicit'; inverseKey: string; inverseType: string; inverseIsAsync: boolean; @@ -56,6 +57,7 @@ function implicitKeyFor(type: string, key: string): string { } function syncMeta(definition: UpgradedMeta, inverseDefinition: UpgradedMeta) { + definition.inverseKind = inverseDefinition.kind; definition.inverseKey = inverseDefinition.key; definition.inverseType = inverseDefinition.type; definition.inverseIsAsync = inverseDefinition.isAsync; @@ -198,7 +200,7 @@ export function upgradeDefinition( type: type, isAsync: false, isImplicit: true, - isCollection: false, + isCollection: true, // with implicits any number of records could point at us isPolymorphic: false, }; diff --git a/packages/record-data/addon/-private/graph/-operations.ts b/packages/record-data/addon/-private/graph/-operations.ts new file mode 100644 index 00000000000..27d92ede531 --- /dev/null +++ b/packages/record-data/addon/-private/graph/-operations.ts @@ -0,0 +1,67 @@ +type CollectionResourceRelationship = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').CollectionResourceRelationship; +type SingleResourceRelationship = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').SingleResourceRelationship; +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; + +export interface Operation { + op: string; +} + +export interface UpdateRelationshipOperation { + op: 'updateRelationship'; + record: StableRecordIdentifier; + field: string; + value: SingleResourceRelationship | CollectionResourceRelationship; +} + +export interface DeleteRecordOperation { + op: 'deleteRecord'; + record: StableRecordIdentifier; + isNew: boolean; +} + +export interface UnknownOperation { + op: 'never'; + record: StableRecordIdentifier; + field: string; +} + +export interface AddToRelatedRecordsOperation { + op: 'addToRelatedRecords'; + record: StableRecordIdentifier; + field: string; // "relationship" propertyName + value: StableRecordIdentifier | StableRecordIdentifier[]; // related record + index?: number; // the index to insert at +} + +export interface RemoveFromRelatedRecordsOperation { + op: 'removeFromRelatedRecords'; + record: StableRecordIdentifier; + field: string; // "relationship" propertyName + value: StableRecordIdentifier | StableRecordIdentifier[]; // related record +} + +export interface ReplaceRelatedRecordOperation { + op: 'replaceRelatedRecord'; + record: StableRecordIdentifier; + field: string; + value: StableRecordIdentifier | null; +} + +export interface ReplaceRelatedRecordsOperation { + op: 'replaceRelatedRecords'; + record: StableRecordIdentifier; + field: string; + value: StableRecordIdentifier[]; +} + +export type RemoteRelationshipOperation = + | UpdateRelationshipOperation + | ReplaceRelatedRecordOperation + | ReplaceRelatedRecordsOperation + | DeleteRecordOperation; + +export type LocalRelationshipOperation = + | ReplaceRelatedRecordsOperation + | ReplaceRelatedRecordOperation + | RemoveFromRelatedRecordsOperation + | AddToRelatedRecordsOperation; diff --git a/packages/record-data/addon/-private/graph/-utils.ts b/packages/record-data/addon/-private/graph/-utils.ts index 3e60b1e51a2..e21776cf8ae 100644 --- a/packages/record-data/addon/-private/graph/-utils.ts +++ b/packages/record-data/addon/-private/graph/-utils.ts @@ -1,3 +1,15 @@ +import { assert, inspect, warn } from '@ember/debug'; + +import { coerceId, recordDataFor as peekRecordData } from '@ember-data/store/-private'; + +type Graph = import('./index').Graph; +type RecordData = import('@ember-data/store/-private/ts-interfaces/record-data').RecordData; +type RelationshipRecordData = import('../ts-interfaces/relationship-record-data').RelationshipRecordData; +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; +type ImplicitRelationship = import('../relationships/state/implicit').default; +type ManyRelationship = import('../relationships/state/has-many').default; +type BelongsToRelationship = import('../relationships/state/belongs-to').default; +type UpdateRelationshipOperation = import('./-operations').UpdateRelationshipOperation; type Dict = import('@ember-data/store/-private/ts-interfaces/utils').Dict; export function expandingGet(cache: Dict>, key1: string, key2: string): T | undefined { @@ -9,3 +21,108 @@ export function expandingSet(cache: Dict>, key1: string, key2: string let mainCache = (cache[key1] = cache[key1] || Object.create(null)); mainCache[key2] = value; } + +export function assertValidRelationshipPayload(graph: Graph, op: UpdateRelationshipOperation) { + const relationship = graph.get(op.record, op.field); + assert(`Cannot update an implicit relationship`, isHasMany(relationship) || isBelongsTo(relationship)); + const payload = op.value; + const { definition, identifier, state } = relationship; + const { type } = identifier; + const { field } = op; + const { isAsync, kind } = definition; + + if (payload.links) { + warn( + `You pushed a record of type '${type}' with a relationship '${field}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, + isAsync || !!payload.data || state.hasReceivedData, + { + id: 'ds.store.push-link-for-sync-relationship', + } + ); + } else if (payload.data) { + if (kind === 'belongsTo') { + assert( + `A ${type} record was pushed into the store with the value of ${field} being ${inspect( + payload.data + )}, but ${field} is a belongsTo relationship so the value must not be an array. You should probably check your data payload or serializer.`, + !Array.isArray(payload.data) + ); + assertRelationshipData(graph.store, identifier, payload.data, definition); + } else if (kind === 'hasMany') { + assert( + `A ${type} record was pushed into the store with the value of ${field} being '${inspect( + payload.data + )}', but ${field} is a hasMany relationship so the value must be an array. You should probably check your data payload or serializer.`, + Array.isArray(payload.data) + ); + if (Array.isArray(payload.data)) { + for (let i = 0; i < payload.data.length; i++) { + assertRelationshipData(graph.store, identifier, payload.data[i], definition); + } + } + } + } +} + +export function isNew(identifier: StableRecordIdentifier): boolean { + if (!identifier.id) { + return true; + } + const recordData = peekRecordData(identifier); + return recordData ? isRelationshipRecordData(recordData) && recordData.isNew() : false; +} + +function isRelationshipRecordData( + recordData: RecordData | RelationshipRecordData +): recordData is RelationshipRecordData { + return typeof (recordData as RelationshipRecordData).isNew === 'function'; +} + +export function isBelongsTo( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is BelongsToRelationship { + return relationship.definition.kind === 'belongsTo'; +} + +export function isImplicit( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is ImplicitRelationship { + return relationship.definition.isImplicit; +} + +export function isHasMany( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is ManyRelationship { + return relationship.definition.kind === 'hasMany'; +} + +export function assertRelationshipData(store, identifier, data, meta) { + assert( + `A ${identifier.type} record was pushed into the store with the value of ${meta.key} being '${JSON.stringify( + data + )}', but ${ + meta.key + } is a belongsTo relationship so the value must not be an array. You should probably check your data payload or serializer.`, + !Array.isArray(data) + ); + assert( + `Encountered a relationship identifier without a type for the ${meta.kind} relationship '${meta.key}' on <${ + identifier.type + }:${identifier.id}>, expected a json-api identifier with type '${meta.type}' but found '${JSON.stringify( + data + )}'. Please check your serializer and make sure it is serializing the relationship payload into a JSON API format.`, + data === null || (typeof data.type === 'string' && data.type.length) + ); + assert( + `Encountered a relationship identifier without an id for the ${meta.kind} relationship '${meta.key}' on <${ + identifier.type + }:${identifier.id}>, expected a json-api identifier but found '${JSON.stringify( + data + )}'. Please check your serializer and make sure it is serializing the relationship payload into a JSON API format.`, + data === null || !!coerceId(data.id) + ); + assert( + `Encountered a relationship identifier with type '${data.type}' for the ${meta.kind} relationship '${meta.key}' on <${identifier.type}:${identifier.id}>, Expected a json-api identifier with type '${meta.type}'. No model was found for '${data.type}'.`, + data === null || !data.type || store._hasModelFor(data.type) + ); +} diff --git a/packages/record-data/addon/-private/graph/index.ts b/packages/record-data/addon/-private/graph/index.ts index ee0745868b9..15f2b45be6d 100644 --- a/packages/record-data/addon/-private/graph/index.ts +++ b/packages/record-data/addon/-private/graph/index.ts @@ -1,18 +1,28 @@ import { assert } from '@ember/debug'; +import { DEBUG } from '@glimmer/env'; import BelongsToRelationship from '../relationships/state/belongs-to'; import ManyRelationship from '../relationships/state/has-many'; -import Relationship from '../relationships/state/relationship'; +import ImplicitRelationship from '../relationships/state/implicit'; import { isLHS, upgradeDefinition } from './-edge-definition'; - +import { assertValidRelationshipPayload, isBelongsTo, isHasMany, isImplicit } from './-utils'; +import addToRelatedRecords from './operations/add-to-related-records'; +import removeFromRelatedRecords from './operations/remove-from-related-records'; +import replaceRelatedRecord from './operations/replace-related-record'; +import replaceRelatedRecords, { syncRemoteToLocal } from './operations/replace-related-records'; +import updateRelationshipOperation from './operations/update-relationship'; + +type DeleteRecordOperation = import('./-operations').DeleteRecordOperation; +type RemoteRelationshipOperation = import('./-operations').RemoteRelationshipOperation; +type UnknownOperation = import('./-operations').UnknownOperation; +type LocalRelationshipOperation = import('./-operations').LocalRelationshipOperation; type Dict = import('@ember-data/store/-private/ts-interfaces/utils').Dict; type EdgeCache = import('./-edge-definition').EdgeCache; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; type Store = import('@ember-data/store/-private/system/core-store').default; type RecordDataStoreWrapper = import('@ember-data/store/-private').RecordDataStoreWrapper; -type JsonApiRelationship = import('@ember-data/store/-private/ts-interfaces/record-data-json-api').JsonApiRelationship; -type RelationshipEdge = Relationship | ManyRelationship | BelongsToRelationship; +type RelationshipEdge = ImplicitRelationship | ManyRelationship | BelongsToRelationship; const Graphs = new WeakMap(); @@ -48,6 +58,15 @@ export function graphFor(store: RecordDataStoreWrapper | Store): Graph { * with increasingly minor alterations to other portions of the internals * over time. * + * The graph is made up of nodes and edges. Each unique identifier gets + * its own node, which is a dictionary with a list of that node's edges + * (or connections) to other nodes. In `Model` terms, a node represents a + * record instance, with each key (an edge) in the dictionary correlating + * to either a `hasMany` or `belongsTo` field on that record instance. + * + * The value for each key, or `edge` is the identifier(s) the node relates + * to in the graph from that key. + * * @internal */ export class Graph { @@ -55,16 +74,24 @@ export class Graph { declare _potentialPolymorphicTypes: Dict>; declare identifiers: Map>; declare store: RecordDataStoreWrapper; - declare _queued: { belongsTo: any[]; hasMany: any[] }; - declare _nextFlush: boolean; + declare _willSyncRemote: boolean; + declare _willSyncLocal: boolean; + declare _pushedUpdates: { + belongsTo: RemoteRelationshipOperation[]; + hasMany: RemoteRelationshipOperation[]; + deletions: DeleteRecordOperation[]; + }; + declare _updatedRelationships: Set; constructor(store: RecordDataStoreWrapper) { this._definitionCache = Object.create(null); this._potentialPolymorphicTypes = Object.create(null); this.identifiers = new Map(); this.store = store; - this._queued = { belongsTo: [], hasMany: [] }; - this._nextFlush = false; + this._willSyncRemote = false; + this._willSyncLocal = false; + this._pushedUpdates = { belongsTo: [], hasMany: [], deletions: [] }; + this._updatedRelationships = new Set(); } has(identifier: StableRecordIdentifier, propertyName: string): boolean { @@ -89,7 +116,11 @@ export class Graph { assert(`Could not determine relationship information for ${identifier.type}.${propertyName}`, info !== null); const meta = isLHS(info, identifier.type, propertyName) ? info.lhs_definition : info.rhs_definition!; const Klass = - meta.kind === 'hasMany' ? ManyRelationship : meta.kind === 'belongsTo' ? BelongsToRelationship : Relationship; + meta.kind === 'hasMany' + ? ManyRelationship + : meta.kind === 'belongsTo' + ? BelongsToRelationship + : ImplicitRelationship; relationship = relationships[propertyName] = new Klass(this, meta, identifier); } @@ -152,6 +183,22 @@ export class Graph { to be do things like remove the comment from the post if the comment were to be deleted. */ + isReleasable(identifier: StableRecordIdentifier): boolean { + const relationships = this.identifiers.get(identifier); + if (!relationships) { + return true; + } + const keys = Object.keys(relationships); + for (let i = 0; i < keys.length; i++) { + const relationship = relationships[keys[i]] as RelationshipEdge; + assert(`Expected a relationship`, relationship); + if (relationship.definition.inverseIsAsync) { + return false; + } + } + return true; + } + unload(identifier: StableRecordIdentifier) { const relationships = this.identifiers.get(identifier); @@ -161,7 +208,7 @@ export class Graph { Object.keys(relationships).forEach((key) => { let rel = relationships[key]!; destroyRelationship(rel); - if (rel.definition.isImplicit) { + if (isImplicit(rel)) { delete relationships[key]; } }); @@ -173,42 +220,132 @@ export class Graph { this.identifiers.delete(identifier); } - push(identifier: StableRecordIdentifier, propertyName: string, payload: JsonApiRelationship) { - const relationship = this.get(identifier, propertyName); - const backburner = this.store._store._backburner; + /** + * Remote state changes + */ + push(op: RemoteRelationshipOperation) { + if (op.op === 'deleteRecord') { + this._pushedUpdates.deletions.push(op); + } else if (op.op === 'replaceRelatedRecord') { + this._pushedUpdates.belongsTo.push(op); + } else { + const relationship = this.get(op.record, op.field); + assert(`Cannot push a remote update for an implicit relationship`, !relationship.definition.isImplicit); + this._pushedUpdates[relationship.definition.kind].push(op); + } + if (!this._willSyncRemote) { + this._willSyncRemote = true; + const backburner = this.store._store._backburner; + backburner.schedule('coalesce', this, this._flushRemoteQueue); + } + } + + /** + * Local state changes + */ + update(op: RemoteRelationshipOperation, isRemote: true): void; + update(op: LocalRelationshipOperation, isRemote?: false): void; + update( + op: LocalRelationshipOperation | RemoteRelationshipOperation | UnknownOperation, + isRemote: boolean = false + ): void { + assert( + `Cannot update an implicit relationship`, + op.op === 'deleteRecord' || !isImplicit(this.get(op.record, op.field)) + ); + + switch (op.op) { + case 'updateRelationship': + assert(`Can only perform the operation updateRelationship on remote state`, isRemote); + if (DEBUG) { + // in debug, assert payload validity eagerly + assertValidRelationshipPayload(this, op); + } + updateRelationshipOperation(this, op); + break; + case 'deleteRecord': { + assert(`Can only perform the operation deleteRelationship on remote state`, isRemote); + const identifier = op.record; + const relationships = this.identifiers.get(identifier); + + if (relationships) { + Object.keys(relationships).forEach((key) => { + const rel = relationships[key]!; + // works together with the has check + delete relationships[key]; + removeCompletelyFromInverse(rel); + }); + this.identifiers.delete(identifier); + } + break; + } + case 'replaceRelatedRecord': + replaceRelatedRecord(this, op, isRemote); + break; + case 'addToRelatedRecords': + addToRelatedRecords(this, op, isRemote); + break; + case 'removeFromRelatedRecords': + removeFromRelatedRecords(this, op, isRemote); + break; + case 'replaceRelatedRecords': + replaceRelatedRecords(this, op, isRemote); + break; + default: + assert(`No local relationship update operation exists for '${op.op}'`); + } + } - this._queued[relationship.definition.kind].push(relationship, payload); - if (this._nextFlush === false) { - backburner.join(() => { - // TODO this join seems to only be necessary for - // some older style tests (causes 7 failures if removed) - backburner.schedule('flushRelationships', this, this.flush); - }); - this._nextFlush = true; + _scheduleLocalSync(relationship) { + this._updatedRelationships.add(relationship); + if (!this._willSyncLocal) { + this._willSyncLocal = true; + const backburner = this.store._store._backburner; + backburner.schedule('sync', this, this._flushLocalQueue); } } - flush() { - this._nextFlush = false; - const { belongsTo, hasMany } = this._queued; - this._queued = { belongsTo: [], hasMany: [] }; - // TODO validate this assumption - // pushing hasMany before belongsTo is a performance win - // as the hasMany's will populate most of the belongsTo's - // and we won't have to do the expensive extra diff. - for (let i = 0; i < hasMany.length; i += 2) { - hasMany[i].push(hasMany[i + 1]); + _flushRemoteQueue() { + if (!this._willSyncRemote) { + return; + } + this._willSyncRemote = false; + const { deletions, hasMany, belongsTo } = this._pushedUpdates; + this._pushedUpdates.deletions = []; + this._pushedUpdates.hasMany = []; + this._pushedUpdates.belongsTo = []; + + for (let i = 0; i < deletions.length; i++) { + this.update(deletions[i], true); + } + + for (let i = 0; i < hasMany.length; i++) { + this.update(hasMany[i], true); } - for (let i = 0; i < belongsTo.length; i += 2) { - belongsTo[i].push(belongsTo[i + 1]); + + for (let i = 0; i < belongsTo.length; i++) { + this.update(belongsTo[i], true); } } - destroy() { + _flushLocalQueue() { + if (!this._willSyncLocal) { + return; + } + this._willSyncLocal = false; + let updated = this._updatedRelationships; + this._updatedRelationships = new Set(); + updated.forEach(syncRemoteToLocal); + } + + willDestroy() { this.identifiers.clear(); - Graphs.delete(this.store); this.store = (null as unknown) as RecordDataStoreWrapper; } + + destroy() { + Graphs.delete(this.store); + } } // Handle dematerialization for relationship `rel`. In all cases, notify the @@ -222,10 +359,79 @@ export class Graph { // disconnected we can actually destroy the internalModel when checking for // orphaned models. function destroyRelationship(rel) { + if (isImplicit(rel)) { + if (rel.graph.isReleasable(rel.identifier)) { + removeCompletelyFromInverse(rel); + } + return; + } + rel.recordDataDidDematerialize(); if (!rel.definition.inverseIsImplicit && !rel.definition.inverseIsAsync) { - rel.removeAllRecordDatasFromOwn(); - rel.removeAllCanonicalRecordDatasFromOwn(); + rel.state.isStale = true; + rel.clear(); + + // necessary to clear relationships in the ui from dematerialized records + // hasMany is managed by InternalModel which calls `retreiveLatest` after + // dematerializing the recordData instance. + // but sync belongsTo require this since they don't have a proxy to update. + // so we have to notify so it will "update" to null. + // we should discuss whether we still care about this, probably fine to just + // leave the ui relationship populated since the record is destroyed and + // internally we've fully cleaned up. + if (!rel.definition.isAsync) { + if (isBelongsTo(rel)) { + rel.notifyBelongsToChange(); + } else { + rel.notifyHasManyChange(); + } + } + } +} + +function removeCompletelyFromInverse(relationship: ImplicitRelationship | ManyRelationship | BelongsToRelationship) { + // we actually want a union of members and canonicalMembers + // they should be disjoint but currently are not due to a bug + const seen = Object.create(null); + const { identifier } = relationship; + const { inverseKey } = relationship.definition; + + const unload = (inverseIdentifier: StableRecordIdentifier) => { + const id = inverseIdentifier.lid; + + if (seen[id] === undefined) { + if (relationship.graph.has(inverseIdentifier, inverseKey)) { + relationship.graph.get(inverseIdentifier, inverseKey).removeCompletelyFromOwn(identifier); + } + seen[id] = true; + } + }; + + if (isBelongsTo(relationship)) { + if (relationship.localState) { + unload(relationship.localState); + } + if (relationship.remoteState) { + unload(relationship.remoteState); + } + + if (!relationship.definition.isAsync) { + relationship.clear(); + } + + relationship.localState = null; + } else if (isHasMany(relationship)) { + relationship.members.forEach(unload); + relationship.canonicalMembers.forEach(unload); + + if (!relationship.definition.isAsync) { + relationship.clear(); + relationship.notifyHasManyChange(); + } + } else { + relationship.members.forEach(unload); + relationship.canonicalMembers.forEach(unload); + relationship.clear(); } } diff --git a/packages/record-data/addon/-private/graph/operations/add-to-related-records.ts b/packages/record-data/addon/-private/graph/operations/add-to-related-records.ts new file mode 100644 index 00000000000..9e439c02d30 --- /dev/null +++ b/packages/record-data/addon/-private/graph/operations/add-to-related-records.ts @@ -0,0 +1,61 @@ +import { assert } from '@ember/debug'; + +import { assertPolymorphicType } from '@ember-data/store/-debug'; + +import { isHasMany } from '../-utils'; +import { addToInverse } from './replace-related-records'; + +type ManyRelationship = import('../../relationships/state/has-many').default; +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; +type Graph = import('../index').Graph; +type AddToRelatedRecordsOperation = import('../-operations').AddToRelatedRecordsOperation; + +export default function addToRelatedRecords(graph: Graph, op: AddToRelatedRecordsOperation, isRemote: boolean) { + const { record, value, index } = op; + const relationship = graph.get(record, op.field); + assert( + `You can only '${op.op}' on a hasMany relationship. ${record.type}.${op.field} is a ${relationship.definition.kind}`, + isHasMany(relationship) + ); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + addRelatedRecord(graph, relationship, record, value[i], index !== undefined ? index + i : index, isRemote); + } + } else { + addRelatedRecord(graph, relationship, record, value, index, isRemote); + } + + relationship.notifyHasManyChange(); +} + +function addRelatedRecord( + graph: Graph, + relationship: ManyRelationship, + record: StableRecordIdentifier, + value: StableRecordIdentifier, + index: number | undefined, + isRemote: boolean +) { + assert(`expected an identifier to add to the relationship`, value); + const { members, currentState } = relationship; + + if (members.has(value)) { + return; + } + + const { type } = relationship.definition; + if (type !== value.type) { + assertPolymorphicType(record, relationship.definition, value, graph.store); + graph.registerPolymorphicType(value.type, type); + } + + relationship.state.hasReceivedData = true; + members.add(value); + if (index === undefined) { + currentState.push(value); + } else { + currentState.splice(index, 0, value); + } + + addToInverse(graph, value, relationship.definition.inverseKey, record, isRemote); +} diff --git a/packages/record-data/addon/-private/graph/operations/remove-from-related-records.ts b/packages/record-data/addon/-private/graph/operations/remove-from-related-records.ts new file mode 100644 index 00000000000..91aea943c0a --- /dev/null +++ b/packages/record-data/addon/-private/graph/operations/remove-from-related-records.ts @@ -0,0 +1,54 @@ +import { assert } from '@ember/debug'; + +import { isHasMany } from '../-utils'; +import { removeFromInverse } from './replace-related-records'; + +type RemoveFromRelatedRecordsOperation = import('../-operations').RemoveFromRelatedRecordsOperation; + +type ManyRelationship = import('../../relationships/state/has-many').default; +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; +type Graph = import('../index').Graph; + +export default function removeFromRelatedRecords( + graph: Graph, + op: RemoveFromRelatedRecordsOperation, + isRemote: boolean +) { + const { record, value } = op; + const relationship = graph.get(record, op.field); + assert( + `You can only '${op.op}' on a hasMany relationship. ${record.type}.${op.field} is a ${relationship.definition.kind}`, + isHasMany(relationship) + ); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + removeRelatedRecord(graph, relationship, record, value[i], isRemote); + } + } else { + removeRelatedRecord(graph, relationship, record, value, isRemote); + } + relationship.notifyHasManyChange(); +} + +function removeRelatedRecord( + graph: Graph, + relationship: ManyRelationship, + record: StableRecordIdentifier, + value: StableRecordIdentifier, + isRemote: boolean +) { + assert(`expected an identifier to add to the relationship`, value); + const { members, currentState } = relationship; + + if (!members.has(value)) { + return; + } + + members.delete(value); + let index = currentState.indexOf(value); + + assert(`expected members and currentState to be in sync`, index !== -1); + currentState.splice(index, 1); + + removeFromInverse(graph, value, relationship.definition.inverseKey, record, isRemote); +} diff --git a/packages/record-data/addon/-private/graph/operations/replace-related-record.ts b/packages/record-data/addon/-private/graph/operations/replace-related-record.ts new file mode 100644 index 00000000000..8836db580af --- /dev/null +++ b/packages/record-data/addon/-private/graph/operations/replace-related-record.ts @@ -0,0 +1,115 @@ +import { assert } from '@ember/debug'; + +import { assertPolymorphicType } from '@ember-data/store/-debug'; + +import { isBelongsTo, isNew } from '../-utils'; +import { addToInverse, removeFromInverse } from './replace-related-records'; + +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; +type ReplaceRelatedRecordOperation = import('../-operations').ReplaceRelatedRecordOperation; +type Graph = import('../index').Graph; + +export default function replaceRelatedRecord(graph: Graph, op: ReplaceRelatedRecordOperation, isRemote = false) { + const relationship = graph.get(op.record, op.field); + assert( + `You can only '${op.op}' on a belongsTo relationship. ${op.record.type}.${op.field} is a ${relationship.definition.kind}`, + isBelongsTo(relationship) + ); + const { definition, state } = relationship; + const prop = isRemote ? 'remoteState' : 'localState'; + const existingState: StableRecordIdentifier | null = relationship[prop]; + + /* + case 1:1 + ======== + In a bi-directional graph with 1:1 edges, replacing a value + results in up-to 4 discrete value transitions. + + If: A <-> B, C <-> D is the initial state, + and: A <-> C, B, D is the final state + + then we would undergo the following 4 transitions. + + remove A from B + add C to A + remove C from D + add A to C + + case 1:many + =========== + In a bi-directional graph with 1:Many edges, replacing a value + results in up-to 3 discrete value transitions. + + If: A<->>B<<->D, C<<->D is the initial state (double arrows representing the many side) + And: A<->>C<<->D, B<<->D is the final state + + Then we would undergo three transitions. + + remove A from B + add C to A. + add A to C + + case 1:? + ======== + In a uni-directional graph with 1:? edges (modeled in EmberData with `inverse:null`) with + artificial (implicit) inverses, replacing a value results in up-to 3 discrete value transitions. + This is because a 1:? relationship is effectively 1:many. + + If: A->B, C->B is the initial state + And: A->C, C->B is the final state + + Then we would undergo three transitions. + + Remove A from B + Add C to A + Add A to C + */ + + // nothing for us to do + if (op.value === existingState) { + // if we were empty before but now know we are empty this needs to be true + state.hasReceivedData = true; + // if this is a remote update we still sync + if (isRemote) { + const { localState } = relationship; + // don't sync if localState is a new record and our canonicalState is null + if ((localState && isNew(localState) && !existingState) || localState === existingState) { + return; + } + relationship.localState = existingState; + relationship.notifyBelongsToChange(); + } + return; + } + + // remove this value from the inverse if required + if (existingState) { + removeFromInverse(graph, existingState, definition.inverseKey, op.record, isRemote); + } + + // update value to the new value + relationship[prop] = op.value; + state.hasReceivedData = true; + state.isEmpty = op.value === null; + + if (op.value) { + if (definition.type !== op.value.type) { + assertPolymorphicType(relationship.identifier, definition, op.value, graph.store); + graph.registerPolymorphicType(definition.type, op.value.type); + } + addToInverse(graph, op.value, definition.inverseKey, op.record, isRemote); + } + + if (isRemote) { + const { localState, remoteState } = relationship; + if (localState && isNew(localState) && !remoteState) { + return; + } + if (localState !== remoteState) { + relationship.localState = remoteState; + relationship.notifyBelongsToChange(); + } + } else { + relationship.notifyBelongsToChange(); + } +} diff --git a/packages/record-data/addon/-private/graph/operations/replace-related-records.ts b/packages/record-data/addon/-private/graph/operations/replace-related-records.ts new file mode 100644 index 00000000000..56a3dfe43e5 --- /dev/null +++ b/packages/record-data/addon/-private/graph/operations/replace-related-records.ts @@ -0,0 +1,339 @@ +import { assert } from '@ember/debug'; + +import { assertPolymorphicType } from '@ember-data/store/-debug'; + +import { isBelongsTo, isHasMany, isNew } from '../-utils'; + +type ManyRelationship = import('../..').ManyRelationship; + +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; + +type ReplaceRelatedRecordsOperation = import('../-operations').ReplaceRelatedRecordsOperation; +type Graph = import('../index').Graph; + +/* + case many:1 + ======== + In a bi-directional graph with Many:1 edges, adding a value + results in up-to 3 discrete value transitions, while removing + a value is only 2 transitions. + + For adding C to A + If: A <<-> B, C <->> D is the initial state, + and: B <->> A <<-> C, D is the final state + + then we would undergo the following transitions. + + add C to A + remove C from D + add A to C + + For removing B from A + If: A <<-> B, C <->> D is the initial state, + and: A, B, C <->> D is the final state + + then we would undergo the following transitions. + + remove B from A + remove A from B + + case many:many + =========== + In a bi-directional graph with Many:Many edges, adding or + removing a value requires only 2 value transitions. + + For Adding + If: A<<->>B, C<<->>D is the initial state (double arrows representing the many side) + And: D<<->>C<<->>A<<->>B is the final state + + Then we would undergo two transitions. + + add C to A. + add A to C + + For Removing + If: A<<->>B, C<<->>D is the initial state (double arrows representing the many side) + And: A, B, C<<->>D is the final state + + Then we would undergo two transitions. + + remove B from A + remove A from B + + case many:? + ======== + In a uni-directional graph with Many:? edges (modeled in EmberData with `inverse:null`) with + artificial (implicit) inverses, replacing a value results in 2 discrete value transitions. + This is because a Many:? relationship is effectively Many:Many. + */ +export default function replaceRelatedRecords(graph: Graph, op: ReplaceRelatedRecordsOperation, isRemote: boolean) { + if (isRemote) { + replaceRelatedRecordsRemote(graph, op, isRemote); + } else { + replaceRelatedRecordsLocal(graph, op, isRemote); + } +} + +function replaceRelatedRecordsLocal(graph: Graph, op: ReplaceRelatedRecordsOperation, isRemote: boolean) { + const identifiers = op.value; + const identifiersLength = identifiers.length; + const relationship = graph.get(op.record, op.field); + assert(`expected hasMany relationship`, isHasMany(relationship)); + relationship.state.hasReceivedData = true; + + const newValues = Object.create(null); + for (let i = 0; i < identifiersLength; i++) { + newValues[identifiers[i].lid] = true; + } + + // cache existing state + const { currentState, members, definition } = relationship; + const newState = new Array(identifiers.length); + const newMembership = new Set(); + + // wipe existing state + relationship.members = newMembership; + relationship.currentState = newState; + + const { type } = relationship.definition; + + let changed = false; + + const currentLength = currentState.length; + const iterationLength = currentLength > identifiersLength ? currentLength : identifiersLength; + const equalLength = currentLength === identifiersLength; + + for (let i = 0; i < iterationLength; i++) { + if (i < identifiersLength) { + const identifier = identifiers[i]; + if (type !== identifier.type) { + assertPolymorphicType(relationship.identifier, relationship.definition, identifier, graph.store); + graph.registerPolymorphicType(type, identifier.type); + } + newState[i] = identifier; + newMembership.add(identifier); + + if (!members.has(identifier)) { + changed = true; + addToInverse(graph, identifier, definition.inverseKey, op.record, isRemote); + } + } + if (i < currentLength) { + const identifier = currentState[i]; + + // detect reordering + if (equalLength && newState[i] !== identifier) { + changed = true; + } + + if (!newValues[identifier.lid]) { + changed = true; + removeFromInverse(graph, identifier, definition.inverseKey, op.record, isRemote); + } + } + } + + if (changed) { + relationship.notifyHasManyChange(); + } +} + +function replaceRelatedRecordsRemote(graph: Graph, op: ReplaceRelatedRecordsOperation, isRemote: boolean) { + const identifiers = op.value; + const identifiersLength = identifiers.length; + const relationship = graph.get(op.record, op.field); + + assert( + `You can only '${op.op}' on a hasMany relationship. ${op.record.type}.${op.field} is a ${relationship.definition.kind}`, + isHasMany(relationship) + ); + relationship.state.hasReceivedData = true; + + const newValues = Object.create(null); + for (let i = 0; i < identifiersLength; i++) { + newValues[identifiers[i].lid] = true; + } + + // cache existing state + const { canonicalState, canonicalMembers, definition } = relationship; + const newState = new Array(identifiers.length); + const newMembership = new Set(); + + // wipe existing state + relationship.canonicalMembers = newMembership; + relationship.canonicalState = newState; + + const { type } = relationship.definition; + + let changed = false; + + const canonicalLength = canonicalState.length; + const iterationLength = canonicalLength > identifiersLength ? canonicalLength : identifiersLength; + const equalLength = canonicalLength === identifiersLength; + + for (let i = 0; i < iterationLength; i++) { + if (i < identifiersLength) { + const identifier = identifiers[i]; + if (type !== identifier.type) { + assertPolymorphicType(relationship.identifier, relationship.definition, identifier, graph.store); + graph.registerPolymorphicType(type, identifier.type); + } + newState[i] = identifier; + newMembership.add(identifier); + + if (!canonicalMembers.has(identifier)) { + changed = true; + addToInverse(graph, identifier, definition.inverseKey, op.record, isRemote); + } + } + if (i < canonicalLength) { + const identifier = canonicalState[i]; + + // detect reordering + if (equalLength && newState[i] !== identifier) { + changed = true; + } + + if (!newValues[identifier.lid]) { + changed = true; + removeFromInverse(graph, identifier, definition.inverseKey, op.record, isRemote); + } + } + } + + if (changed) { + flushCanonical(graph, relationship); + /* + replaceRelatedRecordsLocal( + graph, + { + op: op.op, + record: op.record, + field: op.field, + value: canonicalState, + }, + false + );*/ + } else { + // preserve legacy behavior we want to change but requires some sort + // of deprecation. + flushCanonical(graph, relationship); + } +} + +export function addToInverse( + graph: Graph, + identifier: StableRecordIdentifier, + key: string, + value: StableRecordIdentifier, + isRemote: boolean +) { + const relationship = graph.get(identifier, key); + const { type } = relationship.definition; + + if (type !== value.type) { + assertPolymorphicType(relationship.identifier, relationship.definition, value, graph.store); + graph.registerPolymorphicType(type, value.type); + } + + if (isBelongsTo(relationship)) { + relationship.state.hasReceivedData = true; + relationship.state.isEmpty = false; + + if (isRemote) { + if (relationship.remoteState !== null) { + removeFromInverse(graph, relationship.remoteState, relationship.definition.inverseKey, identifier, isRemote); + } + relationship.remoteState = value; + } + + if (relationship.localState !== value) { + if (!isRemote && relationship.localState) { + removeFromInverse(graph, relationship.localState, relationship.definition.inverseKey, identifier, isRemote); + } + relationship.localState = value; + relationship.notifyBelongsToChange(); + } + } else if (isHasMany(relationship)) { + if (isRemote) { + if (!relationship.canonicalMembers.has(value)) { + relationship.canonicalState.push(value); + relationship.canonicalMembers.add(value); + relationship.state.hasReceivedData = true; + flushCanonical(graph, relationship); + } + } else { + if (!relationship.members.has(value)) { + relationship.currentState.push(value); + relationship.members.add(value); + relationship.state.hasReceivedData = true; + relationship.notifyHasManyChange(); + } + } + } else { + if (isRemote) { + relationship.addCanonicalRecordData(value); + } else { + relationship.addRecordData(value); + } + } +} + +export function removeFromInverse( + graph: Graph, + identifier: StableRecordIdentifier, + key: string, + value: StableRecordIdentifier, + isRemote: boolean +) { + const relationship = graph.get(identifier, key); + + if (isBelongsTo(relationship)) { + relationship.state.isEmpty = true; + if (isRemote) { + relationship.remoteState = null; + } + if (relationship.localState === value) { + relationship.localState = null; + relationship.notifyBelongsToChange(); + } + } else if (isHasMany(relationship)) { + if (isRemote) { + let index = relationship.canonicalState.indexOf(value); + if (index !== -1) { + relationship.canonicalMembers.delete(value); + relationship.canonicalState.splice(index, 1); + } + } + let index = relationship.currentState.indexOf(value); + if (index !== -1) { + relationship.members.delete(value); + relationship.currentState.splice(index, 1); + } + relationship.notifyHasManyChange(); + } else { + if (isRemote) { + relationship.removeCompletelyFromOwn(value); + } else { + relationship.removeRecordData(value); + } + } +} + +export function syncRemoteToLocal(rel: ManyRelationship) { + let toSet = rel.canonicalState; + let newRecordDatas = rel.currentState.filter((recordData) => isNew(recordData) && toSet.indexOf(recordData) === -1); + + rel.currentState = toSet.concat(newRecordDatas); + + let members = (rel.members = new Set()); + rel.canonicalMembers.forEach((v) => members.add(v)); + for (let i = 0; i < newRecordDatas.length; i++) { + members.add(newRecordDatas[i]); + } + rel.notifyHasManyChange(); +} + +function flushCanonical(graph: Graph, rel: ManyRelationship) { + graph._scheduleLocalSync(rel); +} diff --git a/packages/record-data/addon/-private/graph/operations/update-relationship.ts b/packages/record-data/addon/-private/graph/operations/update-relationship.ts new file mode 100644 index 00000000000..24d951ef301 --- /dev/null +++ b/packages/record-data/addon/-private/graph/operations/update-relationship.ts @@ -0,0 +1,147 @@ +import { assert, warn } from '@ember/debug'; + +import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; + +import { isBelongsTo, isHasMany } from '../-utils'; +import _normalizeLink from '../../normalize-link'; + +type ExistingResourceIdentifierObject = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').ExistingResourceIdentifierObject; +type UpdateRelationshipOperation = import('../-operations').UpdateRelationshipOperation; +type Graph = import('../index').Graph; + +/* + Updates the "canonical" or "remote" state of a relationship, replacing any existing + state and blowing away any local changes (excepting new records). +*/ +export default function updateRelationshipOperation(graph: Graph, op: UpdateRelationshipOperation) { + const relationship = graph.get(op.record, op.field); + assert(`Cannot update an implicit relationship`, isHasMany(relationship) || isBelongsTo(relationship)); + const { definition, state, identifier } = relationship; + const { isCollection } = definition; + + const payload = op.value; + + let hasRelationshipDataProperty: boolean = false; + let hasLink: boolean = false; + + if (payload.meta) { + relationship.meta = payload.meta; + } + + if (payload.data !== undefined) { + hasRelationshipDataProperty = true; + if (isCollection) { + // TODO deprecate this case. We + // have tests saying we support it. + if (payload.data === null) { + payload.data = []; + } + assert(`Expected an array`, Array.isArray(payload.data)); + graph.update( + { + op: 'replaceRelatedRecords', + record: identifier, + field: op.field, + value: payload.data.map((i) => graph.store.identifierCache.getOrCreateRecordIdentifier(i)), + }, + true + ); + } else { + graph.update( + { + op: 'replaceRelatedRecord', + record: identifier, + field: op.field, + value: payload.data + ? graph.store.identifierCache.getOrCreateRecordIdentifier(payload.data as ExistingResourceIdentifierObject) + : null, + }, + true + ); + } + } else if (definition.isAsync === false && !state.hasReceivedData) { + hasRelationshipDataProperty = true; + + if (isCollection) { + graph.update( + { + op: 'replaceRelatedRecords', + record: identifier, + field: op.field, + value: [], + }, + true + ); + } else { + graph.update( + { + op: 'replaceRelatedRecord', + record: identifier, + field: op.field, + value: null, + }, + true + ); + } + } + + if (payload.links) { + let originalLinks = relationship.links; + relationship.links = payload.links; + if (payload.links.related) { + let relatedLink = _normalizeLink(payload.links.related); + let currentLink = originalLinks && originalLinks.related ? _normalizeLink(originalLinks.related) : null; + let currentLinkHref = currentLink ? currentLink.href : null; + + if (relatedLink && relatedLink.href && relatedLink.href !== currentLinkHref) { + warn( + `You pushed a record of type '${identifier.type}' with a relationship '${definition.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, + definition.isAsync || state.hasReceivedData, + { + id: 'ds.store.push-link-for-sync-relationship', + } + ); + assert( + `You have pushed a record of type '${identifier.type}' with '${definition.key}' as a link, but the value of that link is not a string.`, + typeof relatedLink.href === 'string' || relatedLink.href === null + ); + hasLink = true; + } + } + } + + /* + Data being pushed into the relationship might contain only data or links, + or a combination of both. + + IF contains only data + IF contains both links and data + state.isEmpty -> true if is empty array (has-many) or is null (belongs-to) + state.hasReceivedData -> true + hasDematerializedInverse -> false + state.isStale -> false + allInverseRecordsAreLoaded -> run-check-to-determine + + IF contains only links + state.isStale -> true + */ + relationship.state.hasFailedLoadAttempt = false; + if (hasRelationshipDataProperty) { + let relationshipIsEmpty = payload.data === null || (Array.isArray(payload.data) && payload.data.length === 0); + + relationship.state.hasReceivedData = true; + relationship.state.isStale = false; + relationship.state.hasDematerializedInverse = false; + relationship.state.isEmpty = relationshipIsEmpty; + } else if (hasLink) { + relationship.state.isStale = true; + + let recordData = identifier; + let storeWrapper = graph.store; + if (CUSTOM_MODEL_CLASS) { + storeWrapper.notifyBelongsToChange(recordData.type, recordData.id, recordData.lid, definition.key); + } else { + storeWrapper.notifyPropertyChange(recordData.type, recordData.id, recordData.lid, definition.key); + } + } +} diff --git a/packages/record-data/addon/-private/index.ts b/packages/record-data/addon/-private/index.ts index 18063a4e366..ba40ca7746e 100644 --- a/packages/record-data/addon/-private/index.ts +++ b/packages/record-data/addon/-private/index.ts @@ -1,5 +1,5 @@ export { default as RecordData } from './record-data'; -export { default as Relationship } from './relationships/state/relationship'; +export { default as Relationship } from './relationships/state/implicit'; export { default as BelongsToRelationship } from './relationships/state/belongs-to'; export { default as ManyRelationship } from './relationships/state/has-many'; export { graphFor, peekGraph } from './graph/index'; diff --git a/packages/record-data/addon/-private/ordered-set.ts b/packages/record-data/addon/-private/ordered-set.ts deleted file mode 100644 index 5ff870f54a4..00000000000 --- a/packages/record-data/addon/-private/ordered-set.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { assert } from '@ember/debug'; - -type Dict = import('@ember-data/store/-private/ts-interfaces/utils').Dict; - -const NULL_POINTER = `null-${Date.now()}`; - -/* - TODO Ember's guidFor returns a new string per-object reference - while ours does not. - - This has surfaced a bug during resurrection - in which Ember's guidFor would return false for `has` since the - resurrected record receives a fresh RecordData instance, leaving - the destroyed record in the set and thus depending on the state flags - for it not appearing elsewhere. We've accounted for this bug in the - updated OrderedSet implementation by doing a reference check: e.g. - the bug is preserved. - - While we convert relationships to identifiers this will be something we - will be forced to address. -*/ -export function guidFor(obj): string { - if (obj === null) { - return NULL_POINTER; - } - - return obj.lid; -} - -export default class OrderedSet { - declare presenceSet: Dict; - declare list: (T | null)[]; - declare size: number; - - constructor() { - this.clear(); - } - - clear() { - this.presenceSet = Object.create(null); - this.list = []; - this.size = 0; - } - - add(obj: T | null): OrderedSet { - let guid = guidFor(obj); - assert(`Expected ${obj} to have an lid`, typeof guid === 'string' && guid !== ''); - let presenceSet = this.presenceSet; - let list = this.list; - - if (presenceSet[guid] !== obj) { - presenceSet[guid] = obj; - this.size = list.push(obj); - } - - return this; - } - - delete(obj: T | null): boolean { - let guid = guidFor(obj); - assert(`Expected ${obj} to have an lid`, typeof guid === 'string' && guid !== ''); - let presenceSet = this.presenceSet; - let list = this.list; - - if (presenceSet[guid] === obj) { - delete presenceSet[guid]; - let index = list.indexOf(obj); - if (index > -1) { - list.splice(index, 1); - } - this.size = list.length; - return true; - } else { - return false; - } - } - - has(obj: T | null): boolean { - if (this.size === 0) { - return false; - } - let guid = guidFor(obj); - assert(`Expected ${obj} to have an lid`, typeof guid === 'string' && guid !== ''); - return this.presenceSet[guid] === obj; - } - - toArray(): (T | null)[] { - return this.list.slice(); - } - - copy(): OrderedSet { - let set = new OrderedSet(); - - for (let prop in this.presenceSet) { - set.presenceSet[prop] = this.presenceSet[prop]; - } - - set.list = this.toArray(); - set.size = this.size; - - return set; - } - - addWithIndex(obj: T | null, idx?: number) { - let guid = guidFor(obj); - assert(`Expected ${obj} to have an lid`, typeof guid === 'string' && guid !== ''); - let presenceSet = this.presenceSet; - let list = this.list; - - if (presenceSet[guid] === obj) { - return; - } - - presenceSet[guid] = obj; - - if (idx === undefined || idx === null) { - list.push(obj); - } else { - list.splice(idx, 0, obj); - } - - this.size += 1; - - return this; - } - - deleteWithIndex(obj: T | null, idx?: number): boolean { - let guid = guidFor(obj); - assert(`Expected ${obj} to have an lid`, typeof guid === 'string' && guid !== ''); - let presenceSet = this.presenceSet; - let list = this.list; - - if (presenceSet[guid] === obj) { - delete presenceSet[guid]; - - assert('object is not present at specified index', idx === undefined || list[idx] === obj); - - let index = idx !== undefined ? idx : list.indexOf(obj); - if (index > -1) { - list.splice(index, 1); - } - this.size = list.length; - return true; - } else { - return false; - } - } -} diff --git a/packages/record-data/addon/-private/record-data.ts b/packages/record-data/addon/-private/record-data.ts index 1d13365545a..a41e0996393 100644 --- a/packages/record-data/addon/-private/record-data.ts +++ b/packages/record-data/addon/-private/record-data.ts @@ -1,16 +1,16 @@ /** @module @ember-data/record-data */ -import { assert, inspect, warn } from '@ember/debug'; +import { assert } from '@ember/debug'; import { assign } from '@ember/polyfills'; import { _backburner as emberBackburner } from '@ember/runloop'; import { isEqual } from '@ember/utils'; -import { DEBUG } from '@glimmer/env'; import { RECORD_DATA_ERRORS, RECORD_DATA_STATE } from '@ember-data/canary-features'; import { recordDataFor, recordIdentifierFor, removeRecordDataFor } from '@ember-data/store/-private'; import coerceId from './coerce-id'; +import { isImplicit } from './graph/-utils'; import { graphFor } from './graph/index'; type RecordData = import('@ember-data/store/-private/ts-interfaces/record-data').RecordData; @@ -170,6 +170,10 @@ export default class RecordDataDefault implements RelationshipRecordData { } _setupRelationships(data) { + // TODO @runspired iterating by definitions instead of by payload keys + // allows relationship payloads to be ignored silently if no relationship + // definition exists. Ensure there's a test for this and then consider + // moving this to an assertion. This check should possibly live in the graph. let relationships = this.storeWrapper.relationshipsDefinitionFor(this.modelName); let keys = Object.keys(relationships); for (let i = 0; i < keys.length; i++) { @@ -179,57 +183,14 @@ export default class RecordDataDefault implements RelationshipRecordData { continue; } - // in debug, assert payload validity eagerly let relationshipData = data.relationships[relationshipName]; - if (DEBUG) { - let storeWrapper = this.storeWrapper; - let recordData = this; - let relationshipMeta = relationships[relationshipName]; - if (!relationshipData || !relationshipMeta) { - continue; - } - - if (relationshipData.links) { - let isAsync = relationshipMeta.options && relationshipMeta.options.async !== false; - let relationship = graphFor(this.storeWrapper).get(this.identifier, relationshipName); - warn( - `You pushed a record of type '${this.modelName}' with a relationship '${relationshipName}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, - isAsync || relationshipData.data || relationship.state.hasReceivedData, - { - id: 'ds.store.push-link-for-sync-relationship', - } - ); - } else if (relationshipData.data) { - if (relationshipMeta.kind === 'belongsTo') { - assert( - `A ${ - this.modelName - } record was pushed into the store with the value of ${relationshipName} being ${inspect( - relationshipData.data - )}, but ${relationshipName} is a belongsTo relationship so the value must not be an array. You should probably check your data payload or serializer.`, - !Array.isArray(relationshipData.data) - ); - assertRelationshipData(storeWrapper, recordData, relationshipData.data, relationshipMeta); - } else if (relationshipMeta.kind === 'hasMany') { - assert( - `A ${ - this.modelName - } record was pushed into the store with the value of ${relationshipName} being '${inspect( - relationshipData.data - )}', but ${relationshipName} is a hasMany relationship so the value must be an array. You should probably check your data payload or serializer.`, - Array.isArray(relationshipData.data) - ); - if (Array.isArray(relationshipData.data)) { - for (let i = 0; i < relationshipData.data.length; i++) { - assertRelationshipData(storeWrapper, recordData, relationshipData.data[i], relationshipMeta); - } - } - } - } - } - - graphFor(this.storeWrapper).push(this.identifier, relationshipName, relationshipData); + graphFor(this.storeWrapper).push({ + op: 'updateRelationship', + record: this.identifier, + field: relationshipName, + value: relationshipData, + }); } } @@ -360,21 +321,33 @@ export default class RecordDataDefault implements RelationshipRecordData { // set a new "current state" via ResourceIdentifiers setDirtyHasMany(key: string, recordDatas: RecordData[]) { - let relationship = graphFor(this.storeWrapper).get(this.identifier, key); - relationship.clear(); - (relationship as ManyRelationship).addRecordDatas(recordDatas.map(recordIdentifierFor)); + graphFor(this.storeWrapper).update({ + op: 'replaceRelatedRecords', + record: this.identifier, + field: key, + value: recordDatas.map(recordIdentifierFor), + }); } // append to "current state" via RecordDatas - addToHasMany(key: string, recordDatas: RecordData[], idx) { - let relationship = graphFor(this.storeWrapper).get(this.identifier, key); - (relationship as ManyRelationship).addRecordDatas(recordDatas.map(recordIdentifierFor), idx); + addToHasMany(key: string, recordDatas: RecordData[], idx?: number) { + graphFor(this.storeWrapper).update({ + op: 'addToRelatedRecords', + record: this.identifier, + field: key, + value: recordDatas.map(recordIdentifierFor), + index: idx, + }); } // remove from "current state" via RecordDatas removeFromHasMany(key: string, recordDatas: RecordData[]) { - let relationship = graphFor(this.storeWrapper).get(this.identifier, key); - (relationship as ManyRelationship).removeRecordDatas(recordDatas.map(recordIdentifierFor)); + graphFor(this.storeWrapper).update({ + op: 'removeFromRelatedRecords', + record: this.identifier, + field: key, + value: recordDatas.map(recordIdentifierFor), + }); } commitWasRejected(identifier?, errors?: JsonApiValidationError[]) { @@ -401,9 +374,12 @@ export default class RecordDataDefault implements RelationshipRecordData { } setDirtyBelongsTo(key: string, recordData: RecordData) { - (graphFor(this.storeWrapper).get(this.identifier, key) as BelongsToRelationship).setRecordData( - recordData ? recordIdentifierFor(recordData) : null - ); + graphFor(this.storeWrapper).update({ + op: 'replaceRelatedRecord', + record: this.identifier, + field: key, + value: recordData ? recordIdentifierFor(recordData) : null, + }); } setDirtyAttribute(key: string, value: any) { @@ -457,15 +433,21 @@ export default class RecordDataDefault implements RelationshipRecordData { _cleanupOrphanedRecordDatas() { let relatedRecordDatas = this._allRelatedRecordDatas(); if (areAllModelsUnloaded(relatedRecordDatas)) { - for (let i = 0; i < relatedRecordDatas.length; ++i) { - let recordData = relatedRecordDatas[i]; - if (!recordData.isDestroyed) { - // TODO @runspired we do not currently destroy RecordData instances *except* via this relationship - // traversal. This seems like an oversight since the store should be able to notify destroy. - removeRecordDataFor(recordData.identifier); - recordData.destroy(); + // we don't have a backburner queue yet since + // we scheduled this into ember's destroy + // disconnectRecord called from destroy will teardown + // relationships. We do this to queue that. + this.storeWrapper._store._backburner.join(() => { + for (let i = 0; i < relatedRecordDatas.length; ++i) { + let recordData = relatedRecordDatas[i]; + if (!recordData.isDestroyed) { + // TODO @runspired we do not currently destroy RecordData instances *except* via this relationship + // traversal. This seems like an oversight since the store should be able to notify destroy. + removeRecordDataFor(recordData.identifier); + recordData.destroy(); + } } - } + }); } this._scheduledDestroy = null; } @@ -494,9 +476,7 @@ export default class RecordDataDefault implements RelationshipRecordData { const initializedRelationshipsArr = Object.keys(initializedRelationships) .map((key) => initializedRelationships[key]!) .filter((rel) => { - // TODO @runspired, not clear we need this distinction but - // we used to have it. - return !rel.definition.isImplicit; + return !isImplicit(rel); }); let i = 0; @@ -511,6 +491,8 @@ export default class RecordDataDefault implements RelationshipRecordData { while (k < members.length) { let member = members[k++]; if (member !== null) { + // TODO this can cause materialization + // do something to avoid that return recordDataFor(member); } } @@ -657,14 +639,14 @@ export default class RecordDataDefault implements RelationshipRecordData { case 'belongsTo': this.setDirtyBelongsTo(name, propertyValue); relationship = graph.get(identifier, name); - relationship.setHasReceivedData(true); - relationship.setRelationshipIsEmpty(false); + relationship.state.hasReceivedData = true; + relationship.state.isEmpty = false; break; case 'hasMany': this.setDirtyHasMany(name, propertyValue); relationship = graph.get(identifier, name); - relationship.setHasReceivedData(true); - relationship.setRelationshipIsEmpty(false); + relationship.state.hasReceivedData = true; + relationship.state.isEmpty = false; break; default: // reflect back (pass-thru) unknown properties @@ -677,8 +659,6 @@ export default class RecordDataDefault implements RelationshipRecordData { } /* - - TODO IGOR AND DAVID this shouldn't be public This method should only be called by records in the `isNew()` state OR once the record has been deleted and that deletion has been persisted. @@ -693,22 +673,11 @@ export default class RecordDataDefault implements RelationshipRecordData { @private */ removeFromInverseRelationships() { - const isNew = this.isNew(); - const graph = graphFor(this.storeWrapper); - const { identifier } = this; - - const relationships = graph.identifiers.get(identifier); - - if (relationships) { - Object.keys(relationships).forEach((key) => { - const rel = relationships[key]!; - rel.removeCompletelyFromInverse(); - if (isNew === true) { - rel.clear(); - } - }); - graph.identifiers.delete(identifier); - } + graphFor(this.storeWrapper).push({ + op: 'deleteRecord', + record: this.identifier, + isNew: this.isNew(), + }); } clientDidCreate() { @@ -802,37 +771,6 @@ export default class RecordDataDefault implements RelationshipRecordData { } } -function assertRelationshipData(store, recordData, data, meta) { - assert( - `A ${recordData.modelName} record was pushed into the store with the value of ${meta.key} being '${JSON.stringify( - data - )}', but ${ - meta.key - } is a belongsTo relationship so the value must not be an array. You should probably check your data payload or serializer.`, - !Array.isArray(data) - ); - assert( - `Encountered a relationship identifier without a type for the ${meta.kind} relationship '${ - meta.key - }' on ${recordData}, expected a json-api identifier with type '${meta.type}' but found '${JSON.stringify( - data - )}'. Please check your serializer and make sure it is serializing the relationship payload into a JSON API format.`, - data === null || (typeof data.type === 'string' && data.type.length) - ); - assert( - `Encountered a relationship identifier without an id for the ${meta.kind} relationship '${ - meta.key - }' on ${recordData}, expected a json-api identifier but found '${JSON.stringify( - data - )}'. Please check your serializer and make sure it is serializing the relationship payload into a JSON API format.`, - data === null || !!coerceId(data.id) - ); - assert( - `Encountered a relationship identifier with type '${data.type}' for the ${meta.kind} relationship '${meta.key}' on ${recordData}, Expected a json-api identifier with type '${meta.type}'. No model was found for '${data.type}'.`, - data === null || !data.type || store._hasModelFor(data.type) - ); -} - function areAllModelsUnloaded(recordDatas) { for (let i = 0; i < recordDatas.length; ++i) { if (recordDatas[i].isRecordInUse()) { @@ -846,11 +784,11 @@ function getLocalState(rel) { if (rel.definition.kind === 'belongsTo') { return rel.localState ? [rel.localState] : []; } - return rel.members.list; + return rel.currentState; } function getRemoteState(rel) { if (rel.definition.kind === 'belongsTo') { return rel.remoteState ? [rel.remoteState] : []; } - return rel.canonicalMembers.list; + return rel.canonicalState; } diff --git a/packages/record-data/addon/-private/relationships/state/belongs-to.ts b/packages/record-data/addon/-private/relationships/state/belongs-to.ts index a5d2615da3e..1fbf33ba967 100644 --- a/packages/record-data/addon/-private/relationships/state/belongs-to.ts +++ b/packages/record-data/addon/-private/relationships/state/belongs-to.ts @@ -1,16 +1,10 @@ -import { assert, inspect, warn } from '@ember/debug'; - -import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; -import { assertPolymorphicType } from '@ember-data/store/-debug'; -import { identifierCacheFor } from '@ember-data/store/-private'; - import { createState } from '../../graph/-state'; -import _normalizeLink from '../../normalize-link'; -import { isNew } from './relationship'; +import { isNew } from '../../graph/-utils'; + +type ManyRelationship = import('../..').ManyRelationship; type UpgradedMeta = import('../../graph/-edge-definition').UpgradedMeta; type Graph = import('../../graph').Graph; -type ExistingResourceIdentifierObject = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').ExistingResourceIdentifierObject; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; type DefaultSingleResourceRelationship = import('../../ts-interfaces/relationship-record-data').DefaultSingleResourceRelationship; @@ -21,7 +15,6 @@ type Meta = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api type RelationshipState = import('../../graph/-state').RelationshipState; type RecordDataStoreWrapper = import('@ember-data/store/-private').RecordDataStoreWrapper; type PaginationLinks = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').PaginationLinks; -type JsonApiRelationship = import('@ember-data/store/-private/ts-interfaces/record-data-json-api').JsonApiRelationship; export default class BelongsToRelationship { declare localState: StableRecordIdentifier | null; @@ -35,7 +28,6 @@ export default class BelongsToRelationship { declare meta: Meta | null; declare links: Links | PaginationLinks | null; - declare willSync: boolean; constructor(graph: Graph, definition: UpgradedMeta, identifier: StableRecordIdentifier) { this.graph = graph; @@ -46,7 +38,6 @@ export default class BelongsToRelationship { this.meta = null; this.links = null; - this.willSync = false; this.localState = null; this.remoteState = null; @@ -60,34 +51,6 @@ export default class BelongsToRelationship { return _state; } - get isNew(): boolean { - return isNew(this.identifier); - } - - setHasReceivedData(value: boolean) { - this.state.hasReceivedData = value; - } - - setHasDematerializedInverse(value: boolean) { - this.state.hasDematerializedInverse = value; - } - - setRelationshipIsStale(value: boolean) { - this.state.isStale = value; - } - - setRelationshipIsEmpty(value: boolean) { - this.state.isEmpty = value; - } - - setShouldForceReload(value: boolean) { - this.state.shouldForceReload = value; - } - - setHasFailedLoadAttempt(value: boolean) { - this.state.hasFailedLoadAttempt = value; - } - recordDataDidDematerialize() { if (this.definition.inverseIsImplicit) { return; @@ -108,7 +71,7 @@ export default class BelongsToRelationship { !(relationship as BelongsToRelationship).localState || this.identifier === (relationship as BelongsToRelationship).localState ) { - relationship.inverseDidDematerialize(this.identifier); + (relationship as BelongsToRelationship | ManyRelationship).inverseDidDematerialize(this.identifier); } }; @@ -128,16 +91,20 @@ export default class BelongsToRelationship { // cache. // if the record being unloaded only exists on the client, we similarly // treat it as a client side delete - this.removeRecordDataFromOwn(inverseRecordData!); + if (this.localState === inverseRecordData && inverseRecordData !== null) { + this.localState = null; + } + if (this.remoteState === inverseRecordData && inverseRecordData !== null) { this.remoteState = null; - this.setHasReceivedData(true); - this.setRelationshipIsEmpty(true); - this.flushCanonicalLater(); - this.setRelationshipIsEmpty(true); + this.state.hasReceivedData = true; + this.state.isEmpty = true; + if (this.localState && !isNew(this.localState)) { + this.localState = null; + } } } else { - this.setHasDematerializedInverse(true); + this.state.hasDematerializedInverse = true; } this.notifyBelongsToChange(); } @@ -165,201 +132,6 @@ export default class BelongsToRelationship { return payload; } - /* - `push` for a relationship allows the store to push a JSON API Relationship - Object onto the relationship. The relationship will then extract and set the - meta, data and links of that relationship. - - `push` use `updateMeta`, `updateData` and `updateLink` to update the state - of the relationship. - */ - push(payload: JsonApiRelationship) { - let hasRelationshipDataProperty = false; - let hasLink = false; - - if (payload.meta) { - this.updateMeta(payload.meta); - } - - if (payload.data !== undefined) { - hasRelationshipDataProperty = true; - this.updateData(payload.data as ExistingResourceIdentifierObject); - } else if (this.definition.isAsync === false && !this.state.hasReceivedData) { - hasRelationshipDataProperty = true; - - this.updateData(null!); - } - - if (payload.links) { - let originalLinks = this.links; - this.updateLinks(payload.links); - if (payload.links.related) { - let relatedLink = _normalizeLink(payload.links.related); - let currentLink = originalLinks && originalLinks.related ? _normalizeLink(originalLinks.related) : null; - let currentLinkHref = currentLink ? currentLink.href : null; - - if (relatedLink && relatedLink.href && relatedLink.href !== currentLinkHref) { - warn( - `You pushed a record of type '${this.identifier.type}' with a relationship '${this.definition.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, - this.definition.isAsync || this.state.hasReceivedData, - { - id: 'ds.store.push-link-for-sync-relationship', - } - ); - assert( - `You have pushed a record of type '${this.identifier.type}' with '${this.definition.key}' as a link, but the value of that link is not a string.`, - typeof relatedLink.href === 'string' || relatedLink.href === null - ); - hasLink = true; - } - } - } - - /* - Data being pushed into the relationship might contain only data or links, - or a combination of both. - - IF contains only data - IF contains both links and data - state.isEmpty -> true if is empty array (has-many) or is null (belongs-to) - state.hasReceivedData -> true - hasDematerializedInverse -> false - state.isStale -> false - allInverseRecordsAreLoaded -> run-check-to-determine - - IF contains only links - state.isStale -> true - */ - this.setHasFailedLoadAttempt(false); - if (hasRelationshipDataProperty) { - let relationshipIsEmpty = payload.data === null; - - this.setHasReceivedData(true); - this.setRelationshipIsStale(false); - this.setHasDematerializedInverse(false); - this.setRelationshipIsEmpty(relationshipIsEmpty); - } else if (hasLink) { - this.setRelationshipIsStale(true); - - let recordData = this.identifier; - let storeWrapper = this.store; - if (CUSTOM_MODEL_CLASS) { - storeWrapper.notifyBelongsToChange(recordData.type, recordData.id, recordData.lid, this.definition.key); - } else { - storeWrapper.notifyPropertyChange(recordData.type, recordData.id, recordData.lid, this.definition.key); - } - } - } - - updateLinks(links: PaginationLinks): void { - this.links = links; - } - - updateMeta(meta: any) { - this.meta = meta; - } - - updateData(resource: ExistingResourceIdentifierObject) { - assert( - `Ember Data expected the data for the ${ - this.definition.key - } relationship on a ${this.identifier.toString()} to be in a JSON API format and include an \`id\` and \`type\` property but it found ${inspect( - resource - )}. Please check your serializer and make sure it is serializing the relationship payload into a JSON API format.`, - resource === null || (resource.id !== undefined && resource.type !== undefined) - ); - - const identifier = resource ? identifierCacheFor(this.store._store).getOrCreateRecordIdentifier(resource) : null; - - if (identifier) { - this.addCanonicalRecordData(identifier); - } else if (this.remoteState) { - this.removeCanonicalRecordData(this.remoteState); - } - this.flushCanonicalLater(); - } - - /** - * External method for updating local state - */ - setRecordData(recordData: StableRecordIdentifier | null) { - if (recordData) { - this.addRecordData(recordData); - } else if (this.localState) { - this.removeRecordData(this.localState); - } - - this.setHasReceivedData(true); - this.setRelationshipIsEmpty(false); - } - - addCanonicalRecordData(recordData: StableRecordIdentifier) { - if (this.remoteState === recordData) { - return; - } - - if (this.remoteState) { - this.removeCanonicalRecordData(this.remoteState); - } - - this.remoteState = recordData; - if (this.definition.type !== recordData.type) { - assertPolymorphicType( - this.store.recordDataFor(this.identifier.type, this.identifier.id, this.identifier.lid), - this.definition, - this.store.recordDataFor(recordData.type, recordData.id, recordData.lid), - this.store._store - ); - this.graph.registerPolymorphicType(this.definition.type, recordData.type); - } - this.graph.get(recordData, this.definition.inverseKey).addCanonicalRecordData(this.identifier); - this.flushCanonicalLater(); - this.setHasReceivedData(true); - this.setRelationshipIsEmpty(false); - } - - addRecordData(recordData: StableRecordIdentifier) { - let existingState = this.localState; - if (existingState === recordData) { - return; - } - - if (this.definition.type !== recordData.type) { - assertPolymorphicType( - this.store.recordDataFor(this.identifier.type, this.identifier.id, this.identifier.lid), - this.definition, - this.store.recordDataFor(recordData.type, recordData.id, recordData.lid), - this.store._store - ); - this.graph.registerPolymorphicType(this.definition.type, recordData.type); - } - - if (existingState) { - this.removeRecordData(existingState); - } - - this.localState = recordData; - this.graph.get(recordData, this.definition.inverseKey).addRecordData(this.identifier); - - this.setHasReceivedData(true); - this.notifyBelongsToChange(); - } - - removeRecordData(inverseIdentifier: StableRecordIdentifier | null) { - if (this.localState === inverseIdentifier && inverseIdentifier !== null) { - const { inverseKey } = this.definition; - this.localState = null; - this.notifyBelongsToChange(); - if (this.graph.has(inverseIdentifier, inverseKey)) { - if (!this.definition.inverseIsImplicit) { - this.graph.get(inverseIdentifier, inverseKey).removeRecordDataFromOwn(this.identifier); - } else { - this.graph.get(inverseIdentifier, inverseKey).removeRecordData(this.identifier); - } - } - } - } - /* Removes the given RecordData from BOTH canonical AND current state. @@ -373,130 +145,22 @@ export default class BelongsToRelationship { if (this.localState === recordData) { this.localState = null; + // This allows dematerialized inverses to be rematerialized + // we shouldn't be notifying here though, figure out where + // a notification was missed elsewhere. this.notifyBelongsToChange(); } } - /** - * can be called by the other side - */ - removeRecordDataFromOwn(recordData: StableRecordIdentifier) { - if (this.localState !== recordData || recordData === null) { - return; - } - this.localState = null; - this.notifyBelongsToChange(); - } - - /** - * can be called by the graph - */ - removeAllRecordDatasFromOwn() { - this.setRelationshipIsStale(true); - this.localState = null; - this.notifyBelongsToChange(); - } - - /** - * can be called by the graph - */ - removeAllCanonicalRecordDatasFromOwn() { - this.remoteState = null; - this.flushCanonicalLater(); - } - - /* - Can be called from the other side - */ - removeCanonicalRecordData(inverseIdentifier: StableRecordIdentifier | null) { - if (this.remoteState === inverseIdentifier && inverseIdentifier !== null) { - this.remoteState = null; - this.setHasReceivedData(true); - this.setRelationshipIsEmpty(true); - this.flushCanonicalLater(); - this.setRelationshipIsEmpty(true); - - const { inverseKey } = this.definition; - if (!this.definition.isImplicit && this.graph.has(inverseIdentifier, inverseKey)) { - this.graph.get(inverseIdentifier, inverseKey).removeCanonicalRecordData(this.identifier); - } - } - } - - /* - Call this method once a record deletion has been persisted - to purge it from BOTH current and canonical state of all - relationships. - - @method removeCompletelyFromInverse - @private - */ - removeCompletelyFromInverse() { - const seen = Object.create(null); - const { identifier } = this; - const { inverseKey } = this.definition; - - const unload = (inverseIdentifier: StableRecordIdentifier) => { - const id = inverseIdentifier.lid; - - if (seen[id] === undefined) { - if (this.graph.has(inverseIdentifier, inverseKey)) { - this.graph.get(inverseIdentifier, inverseKey).removeCompletelyFromOwn(identifier); - } - seen[id] = true; - } - }; - - if (this.localState) { - unload(this.localState); - } - if (this.remoteState) { - unload(this.remoteState); - } - - if (!this.definition.isAsync) { - this.clear(); - } - - this.localState = null; - } - - flushCanonical() { - //temporary fix to not remove newly created records if server returned null. - //TODO remove once we have proper diffing - if (this.localState && isNew(this.localState) && !this.remoteState) { - this.willSync = false; - return; - } - if (this.localState !== this.remoteState) { - this.localState = this.remoteState; - this.notifyBelongsToChange(); - } - this.willSync = false; - } - - flushCanonicalLater() { - if (this.willSync) { - return; - } - this.willSync = true; - // Reaching back into the store to use ED's runloop - this.store._store._updateRelationshipState(this); - } - notifyBelongsToChange() { let recordData = this.identifier; this.store.notifyBelongsToChange(recordData.type, recordData.id, recordData.lid, this.definition.key); } clear() { - if (this.localState) { - this.removeRecordData(this.localState); - } - if (this.remoteState) { - this.removeCanonicalRecordData(this.remoteState); - } + this.localState = null; + this.remoteState = null; + this.state.hasReceivedData = false; + this.state.isEmpty = true; } - - destroy() {} } diff --git a/packages/record-data/addon/-private/relationships/state/has-many.ts b/packages/record-data/addon/-private/relationships/state/has-many.ts index be58dc325b5..1979769c416 100755 --- a/packages/record-data/addon/-private/relationships/state/has-many.ts +++ b/packages/record-data/addon/-private/relationships/state/has-many.ts @@ -1,27 +1,55 @@ -import { isNone } from '@ember/utils'; +import { assert } from '@ember/debug'; import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; -import { assertPolymorphicType } from '@ember-data/store/-debug'; -import { identifierCacheFor } from '@ember-data/store/-private'; -import Relationship, { isNew } from './relationship'; +import { createState } from '../../graph/-state'; +import { isImplicit, isNew } from '../../graph/-utils'; type UpgradedMeta = import('../../graph/-edge-definition').UpgradedMeta; type Graph = import('../../graph').Graph; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; type DefaultCollectionResourceRelationship = import('../../ts-interfaces/relationship-record-data').DefaultCollectionResourceRelationship; +type Links = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').Links; +type Meta = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').Meta; +type RelationshipState = import('../../graph/-state').RelationshipState; +type BelongsToRelationship = import('../..').BelongsToRelationship; +type RecordDataStoreWrapper = import('@ember-data/store/-private').RecordDataStoreWrapper; +type PaginationLinks = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').PaginationLinks; /** @module @ember-data/record-data */ -export default class ManyRelationship extends Relationship { +export default class ManyRelationship { + declare graph: Graph; + declare store: RecordDataStoreWrapper; + declare definition: UpgradedMeta; + declare identifier: StableRecordIdentifier; + declare _state: RelationshipState | null; + + declare members: Set; + declare canonicalMembers: Set; + declare meta: Meta | null; + declare links: Links | PaginationLinks | null; + declare canonicalState: StableRecordIdentifier[]; declare currentState: StableRecordIdentifier[]; declare _willUpdateManyArray: boolean; declare _pendingManyArrayUpdates: any; + constructor(graph: Graph, definition: UpgradedMeta, identifier: StableRecordIdentifier) { - super(graph, definition, identifier); + this.graph = graph; + this.store = graph.store; + this.definition = definition; + this.identifier = identifier; + this._state = null; + + this.members = new Set(); + this.canonicalMembers = new Set(); + + this.meta = null; + this.links = null; + // persisted state this.canonicalState = []; // local client state @@ -30,146 +58,114 @@ export default class ManyRelationship extends Relationship { this._pendingManyArrayUpdates = null; } - addCanonicalRecordData(recordData: StableRecordIdentifier, idx?: number) { - if (this.canonicalMembers.has(recordData)) { - return; - } - if (idx !== undefined) { - this.canonicalState.splice(idx, 0, recordData); - } else { - this.canonicalState.push(recordData); - } - super.addCanonicalRecordData(recordData, idx); - } - - inverseDidDematerialize(inverseRecordData: StableRecordIdentifier) { - super.inverseDidDematerialize(inverseRecordData); - if (this.definition.isAsync) { - this.notifyManyArrayIsStale(); + get state(): RelationshipState { + let { _state } = this; + if (!_state) { + _state = this._state = createState(); } + return _state; } - addRecordData(recordData: StableRecordIdentifier, idx?: number) { - if (this.members.has(recordData)) { + recordDataDidDematerialize() { + if (this.definition.inverseIsImplicit) { return; } - const { store } = this; - if (this.definition.type !== recordData.type) { - assertPolymorphicType( - store.recordDataFor(this.identifier.type, this.identifier.id, this.identifier.lid), - this.definition, - store.recordDataFor(recordData.type, recordData.id, recordData.lid), - store._store - ); - this.graph.registerPolymorphicType(this.definition.type, recordData.type); - } - super.addRecordData(recordData, idx); - // make lazy later - if (idx === undefined) { - idx = this.currentState.length; - } - this.currentState.splice(idx, 0, recordData); - // TODO Igor consider making direct to remove the indirection - // We are not lazily accessing the manyArray here because the change is coming from app side - // this.manyArray.flushCanonical(this.currentState); - this.notifyHasManyChange(); + const inverseKey = this.definition.inverseKey; + this.forAllMembers((inverseIdentifier) => { + inverseIdentifier; + if (!inverseIdentifier || !this.graph.has(inverseIdentifier, inverseKey)) { + return; + } + let relationship = this.graph.get(inverseIdentifier, inverseKey); + assert(`expected no implicit`, !isImplicit(relationship)); + + // For canonical members, it is possible that inverseRecordData has already been associated to + // to another record. For such cases, do not dematerialize the inverseRecordData + if ( + relationship.definition.kind !== 'belongsTo' || + !(relationship as BelongsToRelationship).localState || + this.identifier === (relationship as BelongsToRelationship).localState + ) { + (relationship as ManyRelationship | BelongsToRelationship).inverseDidDematerialize(this.identifier); + } + }); } - removeCanonicalRecordDataFromOwn(recordData: StableRecordIdentifier, idx?: number) { - let i = idx; - if (!this.canonicalMembers.has(recordData)) { - return; - } - if (i === undefined) { - i = this.canonicalState.indexOf(recordData); + forAllMembers(callback: (im: StableRecordIdentifier | null) => void) { + // ensure we don't walk anything twice if an entry is + // in both members and canonicalMembers + let seen = Object.create(null); + + for (let i = 0; i < this.currentState.length; i++) { + const inverseInternalModel = this.currentState[i]; + const id = inverseInternalModel.lid; + if (!seen[id]) { + seen[id] = true; + callback(inverseInternalModel); + } } - if (i > -1) { - this.canonicalState.splice(i, 1); + + for (let i = 0; i < this.canonicalState.length; i++) { + const inverseInternalModel = this.canonicalState[i]; + const id = inverseInternalModel.lid; + if (!seen[id]) { + seen[id] = true; + callback(inverseInternalModel); + } } - super.removeCanonicalRecordDataFromOwn(recordData, idx); - //TODO(Igor) Figure out what to do here } - removeAllCanonicalRecordDatasFromOwn() { - this.canonicalState.splice(0, this.canonicalState.length); - super.removeAllCanonicalRecordDatasFromOwn(); + clear() { + this.members.clear(); + this.canonicalMembers.clear(); + this.currentState = []; + this.canonicalState = []; } - //TODO(Igor) DO WE NEED THIS? - removeCompletelyFromOwn(recordData: StableRecordIdentifier) { - super.removeCompletelyFromOwn(recordData); - - // TODO SkEPTICAL - const canonicalIndex = this.canonicalState.indexOf(recordData); - - if (canonicalIndex !== -1) { - this.canonicalState.splice(canonicalIndex, 1); + inverseDidDematerialize(inverseRecordData: StableRecordIdentifier) { + if (!this.definition.isAsync || (inverseRecordData && isNew(inverseRecordData))) { + // unloading inverse of a sync relationship is treated as a client-side + // delete, so actually remove the models don't merely invalidate the cp + // cache. + // if the record being unloaded only exists on the client, we similarly + // treat it as a client side delete + this.removeCompletelyFromOwn(inverseRecordData); + } else { + this.state.hasDematerializedInverse = true; } - this.removeRecordDataFromOwn(recordData); + // for async relationships this triggers a sync flush + // đŸ˜± + // I believe we do this to force rematerialization. + // however this works in the CUSTOM_MODEL_CLASS branch + // asynchronously. We should figure out why. + this.notifyManyArrayIsStale(); } - flushCanonical() { - let toSet = this.canonicalState; - - //a hack for not removing new records - //TODO remove once we have proper diffing - let newRecordDatas = this.currentState.filter( - // only add new internalModels which are not yet in the canonical state of this - // relationship (a new internalModel can be in the canonical state if it has - // been 'acknowleged' to be in the relationship via a store.push) - - //TODO Igor deal with this - (recordData) => isNew(recordData) && toSet.indexOf(recordData) === -1 - ); - toSet = toSet.concat(newRecordDatas); - - /* - if (this._manyArray) { - this._manyArray.flushCanonical(toSet); - } - */ - this.currentState = toSet; - super.flushCanonical(); - // Once we clean up all the flushing, we will be left with at least the notifying part - this.notifyHasManyChange(); - } + /* + Removes the given RecordData from BOTH canonical AND current state. - //TODO(Igor) idx not used currently, fix - removeRecordDataFromOwn(recordData: StableRecordIdentifier, idx?: number) { - super.removeRecordDataFromOwn(recordData, idx); - let index = idx || this.currentState.indexOf(recordData); + This method is useful when either a deletion or a rollback on a new record + needs to entirely purge itself from an inverse relationship. + */ + removeCompletelyFromOwn(recordData: StableRecordIdentifier) { + this.canonicalMembers.delete(recordData); + this.members.delete(recordData); - //TODO IGOR DAVID INVESTIGATE - if (index === -1) { - return; + const canonicalIndex = this.canonicalState.indexOf(recordData); + if (canonicalIndex !== -1) { + this.canonicalState.splice(canonicalIndex, 1); } - this.currentState.splice(index, 1); - // TODO Igor consider making direct to remove the indirection - // We are not lazily accessing the manyArray here because the change is coming from app side - this.notifyHasManyChange(); - // this.manyArray.flushCanonical(this.currentState); - } - notifyRecordRelationshipAdded() { - this.notifyHasManyChange(); - } - - computeChanges(recordDatas: StableRecordIdentifier[] = []) { - const members = this.canonicalMembers.toArray(); - for (let i = members.length - 1; i >= 0; i--) { - this.removeCanonicalRecordData(members[i], i); - } - for (let i = 0, l = recordDatas.length; i < l; i++) { - this.addCanonicalRecordData(recordDatas[i], i); + const currentIndex = this.currentState.indexOf(recordData); + if (currentIndex !== -1) { + this.currentState.splice(currentIndex, 1); + // This allows dematerialized inverses to be rematerialized + // we shouldn't be notifying here though, figure out where + // a notification was missed elsewhere. + this.notifyHasManyChange(); } - // TODO this flush is here because we may not have triggered one above - // due to the index guard on the remove+add pattern. - // - // while not doing this flush is actually an improvement, for semantics we - // have to preserve the flush cannonical to "lose" local changes - // this.flushCanonicalLater(); } /* @@ -181,7 +177,7 @@ export default class ManyRelationship extends Relationship { */ notifyManyArrayIsStale() { const { store, identifier: recordData } = this; - if (CUSTOM_MODEL_CLASS) { + if (!this.definition.isAsync || CUSTOM_MODEL_CLASS) { store.notifyHasManyChange(recordData.type, recordData.id, recordData.lid, this.definition.key); } else { store.notifyPropertyChange(recordData.type, recordData.id, recordData.lid, this.definition.key); @@ -211,25 +207,4 @@ export default class ManyRelationship extends Relationship { return payload; } - - updateData(data) { - let recordDatas: StableRecordIdentifier[] | undefined; - if (isNone(data)) { - recordDatas = undefined; - } else { - recordDatas = new Array(data.length); - const cache = identifierCacheFor(this.store._store); - for (let i = 0; i < data.length; i++) { - recordDatas[i] = cache.getOrCreateRecordIdentifier(data[i]); - } - } - this.updateRecordDatasFromAdapter(recordDatas); - } - - updateRecordDatasFromAdapter(recordDatas?: StableRecordIdentifier[]) { - this.setHasReceivedData(true); - //TODO(Igor) move this to a proper place - //TODO Once we have adapter support, we need to handle updated and canonical changes - this.computeChanges(recordDatas); - } } diff --git a/packages/record-data/addon/-private/relationships/state/implicit.ts b/packages/record-data/addon/-private/relationships/state/implicit.ts new file mode 100644 index 00000000000..82d29e07066 --- /dev/null +++ b/packages/record-data/addon/-private/relationships/state/implicit.ts @@ -0,0 +1,53 @@ +type Graph = import('../../graph').Graph; +type UpgradedMeta = import('../../graph/-edge-definition').UpgradedMeta; +type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; + +/** + @module @ember-data/store +*/ +export default class ImplicitRelationship { + declare graph: Graph; + declare definition: UpgradedMeta; + declare identifier: StableRecordIdentifier; + + declare members: Set; + declare canonicalMembers: Set; + + constructor(graph: Graph, definition: UpgradedMeta, identifier: StableRecordIdentifier) { + this.graph = graph; + this.definition = definition; + this.identifier = identifier; + + this.members = new Set(); + this.canonicalMembers = new Set(); + } + + addCanonicalRecordData(recordData: StableRecordIdentifier, idx?: number) { + if (!this.canonicalMembers.has(recordData)) { + this.canonicalMembers.add(recordData); + this.members.add(recordData); + } + } + + addRecordData(recordData: StableRecordIdentifier, idx?: number) { + if (!this.members.has(recordData)) { + this.members.add(recordData); + } + } + + removeRecordData(recordData: StableRecordIdentifier | null) { + if (recordData && this.members.has(recordData)) { + this.members.delete(recordData); + } + } + + removeCompletelyFromOwn(recordData: StableRecordIdentifier) { + this.canonicalMembers.delete(recordData); + this.members.delete(recordData); + } + + clear() { + this.canonicalMembers.clear(); + this.members.clear(); + } +} diff --git a/packages/record-data/addon/-private/relationships/state/relationship.ts b/packages/record-data/addon/-private/relationships/state/relationship.ts deleted file mode 100644 index f022357baaf..00000000000 --- a/packages/record-data/addon/-private/relationships/state/relationship.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { assert, warn } from '@ember/debug'; - -import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; -import { assertPolymorphicType } from '@ember-data/store/-debug'; -import { recordDataFor as peekRecordData } from '@ember-data/store/-private'; - -import { createState } from '../../graph/-state'; -import _normalizeLink from '../../normalize-link'; -import OrderedSet, { guidFor } from '../../ordered-set'; - -type Links = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').Links; - -type Meta = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').Meta; - -type Graph = import('../../graph').Graph; -type UpgradedMeta = import('../../graph/-edge-definition').UpgradedMeta; -type RelationshipState = import('../../graph/-state').RelationshipState; -type BelongsToRelationship = import('../..').BelongsToRelationship; -type RecordDataStoreWrapper = import('@ember-data/store/-private').RecordDataStoreWrapper; -type RecordData = import('@ember-data/store/-private/ts-interfaces/record-data').RecordData; -type RelationshipRecordData = import('../../ts-interfaces/relationship-record-data').RelationshipRecordData; -type PaginationLinks = import('@ember-data/store/-private/ts-interfaces/ember-data-json-api').PaginationLinks; -type JsonApiRelationship = import('@ember-data/store/-private/ts-interfaces/record-data-json-api').JsonApiRelationship; -type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; - -/** - @module @ember-data/store -*/ -export function isNew(identifier: StableRecordIdentifier): boolean { - if (!identifier.id) { - return true; - } - const recordData = peekRecordData(identifier); - return recordData ? isRelationshipRecordData(recordData) && recordData.isNew() : false; -} - -export function isRelationshipRecordData( - recordData: RecordData | RelationshipRecordData -): recordData is RelationshipRecordData { - return typeof (recordData as RelationshipRecordData).isNew === 'function'; -} - -export default class Relationship { - declare graph: Graph; - declare store: RecordDataStoreWrapper; - declare definition: UpgradedMeta; - declare identifier: StableRecordIdentifier; - declare _state: RelationshipState | null; - - declare members: OrderedSet; - declare canonicalMembers: OrderedSet; - declare meta: Meta | null; - declare links: Links | PaginationLinks | null; - declare willSync: boolean; - - constructor(graph: Graph, definition: UpgradedMeta, identifier: StableRecordIdentifier) { - this.graph = graph; - this.store = graph.store; - this.definition = definition; - this.identifier = identifier; - this._state = null; - - this.members = new OrderedSet(); - this.canonicalMembers = new OrderedSet(); - - this.meta = null; - this.links = null; - this.willSync = false; - } - - get state(): RelationshipState { - let { _state } = this; - if (!_state) { - _state = this._state = createState(); - } - return _state; - } - - get isNew(): boolean { - return isNew(this.identifier); - } - - recordDataDidDematerialize() { - if (this.definition.inverseIsImplicit) { - return; - } - - const inverseKey = this.definition.inverseKey; - this.forAllMembers((inverseIdentifier) => { - inverseIdentifier; - if (!inverseIdentifier || !this.graph.has(inverseIdentifier, inverseKey)) { - return; - } - let relationship = this.graph.get(inverseIdentifier, inverseKey); - - // For canonical members, it is possible that inverseRecordData has already been associated to - // to another record. For such cases, do not dematerialize the inverseRecordData - if ( - relationship.definition.kind !== 'belongsTo' || - !(relationship as BelongsToRelationship).localState || - this.identifier === (relationship as BelongsToRelationship).localState - ) { - relationship.inverseDidDematerialize(this.identifier); - } - }); - } - - forAllMembers(callback: (im: StableRecordIdentifier | null) => void) { - // ensure we don't walk anything twice if an entry is - // in both members and canonicalMembers - let seen = Object.create(null); - - for (let i = 0; i < this.members.list.length; i++) { - const inverseInternalModel = this.members.list[i]; - const id = guidFor(inverseInternalModel); - if (!seen[id]) { - seen[id] = true; - callback(inverseInternalModel); - } - } - - for (let i = 0; i < this.canonicalMembers.list.length; i++) { - const inverseInternalModel = this.canonicalMembers.list[i]; - const id = guidFor(inverseInternalModel); - if (!seen[id]) { - seen[id] = true; - callback(inverseInternalModel); - } - } - } - - inverseDidDematerialize(inverseRecordData: StableRecordIdentifier | null) { - if (!this.definition.isAsync || (inverseRecordData && isNew(inverseRecordData))) { - // unloading inverse of a sync relationship is treated as a client-side - // delete, so actually remove the models don't merely invalidate the cp - // cache. - // if the record being unloaded only exists on the client, we similarly - // treat it as a client side delete - this.removeRecordDataFromOwn(inverseRecordData); - this.removeCanonicalRecordDataFromOwn(inverseRecordData); - this.setRelationshipIsEmpty(true); - } else { - this.setHasDematerializedInverse(true); - } - } - - updateMeta(meta: any) { - this.meta = meta; - } - - clear() { - let members = this.members.list; - while (members.length > 0) { - let member = members[0]; - this.removeRecordData(member); - } - - let canonicalMembers = this.canonicalMembers.list; - while (canonicalMembers.length > 0) { - let member = canonicalMembers[0]; - this.removeCanonicalRecordData(member); - } - } - - removeAllRecordDatasFromOwn() { - this.setRelationshipIsStale(true); - this.members.clear(); - } - - removeAllCanonicalRecordDatasFromOwn() { - this.canonicalMembers.clear(); - this.flushCanonicalLater(); - } - - removeRecordDatas(recordDatas: StableRecordIdentifier[]) { - recordDatas.forEach((recordData) => this.removeRecordData(recordData)); - } - - addRecordDatas(recordDatas: StableRecordIdentifier[], idx?: number) { - recordDatas.forEach((recordData) => { - this.addRecordData(recordData, idx); - if (idx !== undefined) { - idx++; - } - }); - } - - addCanonicalRecordData(recordData: StableRecordIdentifier, idx?: number) { - if (!this.canonicalMembers.has(recordData)) { - if (this.definition.type !== recordData.type) { - assertPolymorphicType( - this.store.recordDataFor(this.identifier.type, this.identifier.id, this.identifier.lid), - this.definition, - this.store.recordDataFor(recordData.type, recordData.id, recordData.lid), - this.store._store - ); - this.graph.registerPolymorphicType(this.definition.type, recordData.type); - } - this.canonicalMembers.add(recordData); - this.graph.get(recordData, this.definition.inverseKey).addCanonicalRecordData(this.identifier); - } - this.flushCanonicalLater(); - this.setHasReceivedData(true); - } - - removeCanonicalRecordDatas(recordDatas: StableRecordIdentifier[], idx?: number) { - for (let i = 0; i < recordDatas.length; i++) { - if (idx !== undefined) { - this.removeCanonicalRecordData(recordDatas[i], i + idx); - } else { - this.removeCanonicalRecordData(recordDatas[i]); - } - } - } - - removeCanonicalRecordData(recordData: StableRecordIdentifier | null, idx?: number) { - if (this.canonicalMembers.has(recordData)) { - this.removeCanonicalRecordDataFromOwn(recordData, idx); - - if (!recordData || this.definition.isImplicit) { - return; - } - - const { inverseKey } = this.definition; - if (this.graph.has(recordData, inverseKey)) { - this.graph.get(recordData, inverseKey).removeCanonicalRecordData(this.identifier); - } - - this.flushCanonicalLater(); // TODO does this need to be in the outer context - } - } - - addRecordData(recordData: StableRecordIdentifier, idx?: number) { - if (!this.members.has(recordData)) { - this.members.addWithIndex(recordData, idx); - this.notifyRecordRelationshipAdded(recordData, idx); - - this.graph.get(recordData, this.definition.inverseKey).addRecordData(this.identifier); - } - this.setHasReceivedData(true); - } - - removeRecordData(recordData: StableRecordIdentifier | null) { - if (this.members.has(recordData)) { - this.removeRecordDataFromOwn(recordData); - if (!this.definition.inverseIsImplicit) { - this.removeRecordDataFromInverse(recordData); - } else { - if (!recordData) { - return; - } - const { inverseKey } = this.definition; - // TODO is this check ever false? - if (this.graph.has(recordData, inverseKey)) { - this.graph.get(recordData, inverseKey).removeRecordData(this.identifier); - } - } - } - } - - removeRecordDataFromInverse(recordData: StableRecordIdentifier | null) { - if (!recordData) { - return; - } - if (!this.definition.inverseIsImplicit) { - let inverseRelationship = this.graph.get(recordData, this.definition.inverseKey); - //Need to check for existence, as the record might unloading at the moment - if (inverseRelationship) { - inverseRelationship.removeRecordDataFromOwn(this.identifier); - } - } - } - - removeRecordDataFromOwn(recordData: StableRecordIdentifier | null, idx?: number) { - this.members.delete(recordData); - } - - removeCanonicalRecordDataFromOwn(recordData: StableRecordIdentifier | null, idx?: number) { - this.canonicalMembers.deleteWithIndex(recordData, idx); - this.flushCanonicalLater(); - } - - /* - Call this method once a record deletion has been persisted - to purge it from BOTH current and canonical state of all - relationships. - - @method removeCompletelyFromInverse - @private - */ - removeCompletelyFromInverse() { - // we actually want a union of members and canonicalMembers - // they should be disjoint but currently are not due to a bug - const seen = Object.create(null); - const { identifier } = this; - const { inverseKey } = this.definition; - - const unload = (inverseIdentifier: StableRecordIdentifier) => { - const id = inverseIdentifier.lid; - - if (seen[id] === undefined) { - if (this.graph.has(inverseIdentifier, inverseKey)) { - this.graph.get(inverseIdentifier, inverseKey).removeCompletelyFromOwn(identifier); - } - seen[id] = true; - } - }; - - this.members.toArray().forEach(unload); - this.canonicalMembers.toArray().forEach(unload); - - if (!this.definition.isAsync) { - this.clear(); - } - } - - /* - Removes the given RecordData from BOTH canonical AND current state. - - This method is useful when either a deletion or a rollback on a new record - needs to entirely purge itself from an inverse relationship. - */ - removeCompletelyFromOwn(recordData: StableRecordIdentifier) { - this.canonicalMembers.delete(recordData); - this.members.delete(recordData); - } - - flushCanonical() { - let list = this.members.list as StableRecordIdentifier[]; - this.willSync = false; - //a hack for not removing new RecordDatas - //TODO remove once we have proper diffing - let newRecordDatas: StableRecordIdentifier[] = []; - for (let i = 0; i < list.length; i++) { - // TODO Igor deal with this - if (isNew(list[i])) { - newRecordDatas.push(list[i]); - } - } - - //TODO(Igor) make this less abysmally slow - this.members = this.canonicalMembers.copy(); - for (let i = 0; i < newRecordDatas.length; i++) { - this.members.add(newRecordDatas[i]); - } - } - - flushCanonicalLater() { - if (this.willSync) { - return; - } - this.willSync = true; - // Reaching back into the store to use ED's runloop - this.store._store._updateRelationshipState(this); - } - - updateLinks(links: PaginationLinks): void { - this.links = links; - } - - notifyRecordRelationshipAdded(recordData?, idxs?) {} - - setHasReceivedData(value: boolean) { - this.state.hasReceivedData = value; - } - - setHasDematerializedInverse(value: boolean) { - this.state.hasDematerializedInverse = value; - } - - setRelationshipIsStale(value: boolean) { - this.state.isStale = value; - } - - setRelationshipIsEmpty(value: boolean) { - this.state.isEmpty = value; - } - - setShouldForceReload(value: boolean) { - this.state.shouldForceReload = value; - } - - setHasFailedLoadAttempt(value: boolean) { - this.state.hasFailedLoadAttempt = value; - } - - /* - `push` for a relationship allows the store to push a JSON API Relationship - Object onto the relationship. The relationship will then extract and set the - meta, data and links of that relationship. - - `push` use `updateMeta`, `updateData` and `updateLink` to update the state - of the relationship. - */ - push(payload: JsonApiRelationship) { - let hasRelationshipDataProperty = false; - let hasLink = false; - - if (payload.meta) { - this.updateMeta(payload.meta); - } - - if (payload.data !== undefined) { - hasRelationshipDataProperty = true; - this.updateData(payload.data); - } else if (this.definition.isAsync === false && !this.state.hasReceivedData) { - hasRelationshipDataProperty = true; - let data = this.definition.kind === 'hasMany' ? [] : null; - - this.updateData(data); - } - - if (payload.links) { - let originalLinks = this.links; - this.updateLinks(payload.links); - if (payload.links.related) { - let relatedLink = _normalizeLink(payload.links.related); - let currentLink = originalLinks && originalLinks.related ? _normalizeLink(originalLinks.related) : null; - let currentLinkHref = currentLink ? currentLink.href : null; - - if (relatedLink && relatedLink.href && relatedLink.href !== currentLinkHref) { - warn( - `You pushed a record of type '${this.identifier.type}' with a relationship '${this.definition.key}' configured as 'async: false'. You've included a link but no primary data, this may be an error in your payload. EmberData will treat this relationship as known-to-be-empty.`, - this.definition.isAsync || this.state.hasReceivedData, - { - id: 'ds.store.push-link-for-sync-relationship', - } - ); - assert( - `You have pushed a record of type '${this.identifier.type}' with '${this.definition.key}' as a link, but the value of that link is not a string.`, - typeof relatedLink.href === 'string' || relatedLink.href === null - ); - hasLink = true; - } - } - } - - /* - Data being pushed into the relationship might contain only data or links, - or a combination of both. - - IF contains only data - IF contains both links and data - state.isEmpty -> true if is empty array (has-many) or is null (belongs-to) - state.hasReceivedData -> true - hasDematerializedInverse -> false - state.isStale -> false - allInverseRecordsAreLoaded -> run-check-to-determine - - IF contains only links - state.isStale -> true - */ - this.setHasFailedLoadAttempt(false); - if (hasRelationshipDataProperty) { - let relationshipIsEmpty = payload.data === null || (Array.isArray(payload.data) && payload.data.length === 0); - - this.setHasReceivedData(true); - this.setRelationshipIsStale(false); - this.setHasDematerializedInverse(false); - this.setRelationshipIsEmpty(relationshipIsEmpty); - } else if (hasLink) { - this.setRelationshipIsStale(true); - - let recordData = this.identifier; - let storeWrapper = this.store; - if (CUSTOM_MODEL_CLASS) { - storeWrapper.notifyBelongsToChange(recordData.type, recordData.id, recordData.lid, this.definition.key); - } else { - storeWrapper.notifyPropertyChange(recordData.type, recordData.id, recordData.lid, this.definition.key); - } - } - } - - updateData(payload?) {} - - destroy() {} -} diff --git a/packages/record-data/tests/integration/graph/edge-removal/helpers.ts b/packages/record-data/tests/integration/graph/edge-removal/helpers.ts index 036c553e09e..9663557acbc 100644 --- a/packages/record-data/tests/integration/graph/edge-removal/helpers.ts +++ b/packages/record-data/tests/integration/graph/edge-removal/helpers.ts @@ -6,7 +6,7 @@ import { recordIdentifierFor } from '@ember-data/store'; import { stateOf } from './setup'; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; -type Relationship = import('@ember-data/record-data/-private').Relationship; +type ImplicitRelationship = import('@ember-data/record-data/-private').Relationship; type Context = import('./setup').Context; type UserRecord = import('./setup').UserRecord; @@ -189,8 +189,8 @@ export async function setInitialState(context: Context, config: TestConfig, asse assert.strictEqual(Object.keys(chrisImplicits).length, 1, 'PreCond: Chris has one implicit relationship'); - const chrisImplicitFriend = chrisImplicits[chrisBestFriend.definition.inverseKey] as Relationship; - const johnImplicitFriend = johnImplicits[johnBestFriend.definition.inverseKey] as Relationship; + const chrisImplicitFriend = chrisImplicits[chrisBestFriend.definition.inverseKey] as ImplicitRelationship; + const johnImplicitFriend = johnImplicits[johnBestFriend.definition.inverseKey] as ImplicitRelationship; assert.ok(chrisImplicitFriend, 'PreCond: Chris has an implicit best friend'); @@ -270,13 +270,8 @@ export async function testFinalState( config.relType === 'hasMany' && !!config.inverseNull; - // related to above another WAT that refactor should cleanup - // in this case we don't care if sync/async - const isUnloadOfImplictHasManyWithLocalChange = - !!config.isUnloadAsDelete && !!config.dirtyLocal && config.relType === 'hasMany' && !!config.inverseNull; - - // a final WAT likely related to the first two, persisted delete w/o unload of - // a sync hasMany with local changes is not cleared. This final WAT is handled + // a second WAT likely related to the first, persisted delete w/o unload of + // a sync hasMany with local changes is not cleared. This WAT is handled // within the abstract-edge-removal-test configuration. // in the dirtyLocal and useCreate case there is no remote data @@ -288,12 +283,12 @@ export async function testFinalState( // as the RecordData is in an empty state but not destroyed. const johnRemoteRemoved = config.dirtyLocal || config.useCreate || (!config.isUnloadAsDelete && statuses.removed); const johnLocalRemoved = !config.isUnloadAsDelete && statuses.removed; - const johnCleared = statuses.cleared || isUnloadOfImplictHasManyWithLocalChange; + const johnCleared = statuses.cleared; const _removed = config.isUnloadAsDelete ? statuses.cleared && statuses.removed : statuses.removed; // in the dirtyLocal and useCreate case there is no remote data const chrisImplicitRemoteRemoved = config.dirtyLocal || config.useCreate || _removed; - const chrisImplicitLocalRemoved = _removed || isUnloadOfImplictHasManyWithLocalChange; + const chrisImplicitLocalRemoved = _removed; const johnImplicitsCleared = statuses.implicitCleared || statuses.cleared; // in the dirtyLocal and useCreate case there is no remote data const johnImplicitRemoteRemoved = config.dirtyLocal || config.useCreate || statuses.removed; @@ -354,7 +349,7 @@ export async function testFinalState( assert.strictEqual(Object.keys(chrisImplicits).length, 1, 'Result: Chris has one implicit relationship key'); - const chrisImplicitFriend = chrisImplicits[testState.chrisInverseKey] as Relationship; + const chrisImplicitFriend = chrisImplicits[testState.chrisInverseKey] as ImplicitRelationship; assert.ok(chrisImplicitFriend, 'Result: Chris has an implicit relationship for best friend'); const chrisImplicitState = stateOf(chrisImplicitFriend); @@ -378,7 +373,7 @@ export async function testFinalState( assert.false(graph.implicit.has(johnIdentifier), 'implicit cache for john has been removed'); } else { const johnImplicits = graph.getImplicit(johnIdentifier); - const johnImplicitFriend = johnImplicits[testState.johnInverseKey] as Relationship; + const johnImplicitFriend = johnImplicits[testState.johnInverseKey] as ImplicitRelationship; assert.strictEqual( Object.keys(johnImplicits).length, 1, diff --git a/packages/record-data/tests/integration/graph/edge-removal/setup.ts b/packages/record-data/tests/integration/graph/edge-removal/setup.ts index 63630883991..d4a64a5445d 100644 --- a/packages/record-data/tests/integration/graph/edge-removal/setup.ts +++ b/packages/record-data/tests/integration/graph/edge-removal/setup.ts @@ -14,7 +14,7 @@ type SingleResourceDocument = import('@ember-data/store/-private/ts-interfaces/e type BelongsToRelationship = import('@ember-data/record-data/-private').BelongsToRelationship; type CoreStore = import('@ember-data/store/-private/system/core-store').default; type Dict = import('@ember-data/store/-private/ts-interfaces/utils').Dict; -type Relationship = import('@ember-data/record-data/-private').Relationship; +type ImplicitRelationship = import('@ember-data/record-data/-private').Relationship; type StableRecordIdentifier = import('@ember-data/store/-private/ts-interfaces/identifier').StableRecordIdentifier; class AbstractMap { @@ -42,17 +42,17 @@ class AbstractGraph { get( identifier: StableRecordIdentifier, propertyName: string - ): ManyRelationship | BelongsToRelationship | Relationship { + ): ManyRelationship | BelongsToRelationship | ImplicitRelationship { return graphFor(this.store._storeWrapper).get(identifier, propertyName); } - getImplicit(identifier: StableRecordIdentifier): Dict { + getImplicit(identifier: StableRecordIdentifier): Dict { const rels = graphFor(this.store._storeWrapper).identifiers.get(identifier); let implicits = Object.create(null); if (rels) { Object.keys(rels).forEach((key) => { let rel = rels[key]!; - if (rel.definition.isImplicit) { + if (isImplicit(rel)) { implicits[key] = rel; } }); @@ -65,19 +65,49 @@ function graphForTest(store: CoreStore) { return new AbstractGraph(store); } -function isBelongsTo(rel: BelongsToRelationship | ManyRelationship | Relationship): rel is BelongsToRelationship { - return rel.definition.kind === 'belongsTo'; +export function isBelongsTo( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is BelongsToRelationship { + return relationship.definition.kind === 'belongsTo'; } -export function stateOf(rel: BelongsToRelationship | ManyRelationship | Relationship) { - let local, remote; +export function isImplicit( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is ImplicitRelationship { + return relationship.definition.isImplicit; +} + +export function isHasMany( + relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship +): relationship is ManyRelationship { + return relationship.definition.kind === 'hasMany'; +} + +// Set.entries() and Set.values() +// ...set and Array.from(set) don't +// work in IE11 +function setToArray(set: Set): T[] { + let arr: T[] = []; + set.forEach((v) => arr.push(v)); + return arr; +} + +export function stateOf( + rel: BelongsToRelationship | ManyRelationship | ImplicitRelationship +): { remote: StableRecordIdentifier[]; local: StableRecordIdentifier[] } { + let local: StableRecordIdentifier[]; + let remote: StableRecordIdentifier[]; + if (isBelongsTo(rel)) { // we cast these to array form to make the tests more legible local = rel.localState ? [rel.localState] : []; remote = rel.remoteState ? [rel.remoteState] : []; + } else if (isHasMany(rel)) { + local = rel.currentState.filter((m) => m !== null) as StableRecordIdentifier[]; + remote = rel.canonicalState.filter((m) => m !== null) as StableRecordIdentifier[]; } else { - local = rel.members.list.map((m) => (m ? m : null)); - remote = rel.canonicalMembers.list.map((m) => (m ? m : null)); + local = setToArray(rel.members); + remote = setToArray(rel.canonicalMembers); } return { local, diff --git a/packages/record-data/tests/integration/graph/edge-test.ts b/packages/record-data/tests/integration/graph/edge-test.ts index 2569631ba80..53357e1e5e2 100644 --- a/packages/record-data/tests/integration/graph/edge-test.ts +++ b/packages/record-data/tests/integration/graph/edge-test.ts @@ -1,3 +1,5 @@ +import settled from '@ember/test-helpers/settled'; + import { module, test } from 'qunit'; import { setupTest } from 'ember-qunit'; @@ -118,7 +120,15 @@ module('Integration | Graph | Edges', function (hooks) { assert.deepEqual(state.local, [identifier2], 'Our initial current state is correct'); const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); - bestFriend.push({ data: identifier3 }); + graph.update( + { + op: 'replaceRelatedRecord', + record: identifier, + field: 'bestFriend', + value: identifier3, + }, + true + ); state = stateOf(bestFriend); assert.deepEqual(state.remote, [identifier3], 'Our canonical state is correct after canonical update'); @@ -129,7 +139,12 @@ module('Integration | Graph | Edges', function (hooks) { 'We still have no record data instance after updating the canonical state' ); - bestFriend.setRecordData(identifier2); + graph.update({ + op: 'replaceRelatedRecord', + record: identifier, + field: 'bestFriend', + value: identifier2, + }); state = stateOf(bestFriend); assert.deepEqual(state.remote, [identifier3], 'Our canonical state is correct after local update'); @@ -186,7 +201,15 @@ module('Integration | Graph | Edges', function (hooks) { assert.deepEqual(state.local, [identifier2], 'Our initial current state is correct'); const identifier3 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '3' }); - bestFriend.push({ data: identifier3 }); + graph.update( + { + op: 'replaceRelatedRecord', + record: identifier, + field: 'bestFriend', + value: identifier3, + }, + true + ); state = stateOf(bestFriend); assert.deepEqual(state.remote, [identifier3], 'Our canonical state is correct after canonical update'); @@ -197,7 +220,12 @@ module('Integration | Graph | Edges', function (hooks) { 'We still have no record data instance after updating the canonical state' ); - bestFriend.setRecordData(identifier2); + graph.update({ + op: 'replaceRelatedRecord', + record: identifier, + field: 'bestFriend', + value: identifier2, + }); state = stateOf(bestFriend); assert.deepEqual(state.remote, [identifier3], 'Our canonical state is correct after local update'); @@ -258,7 +286,13 @@ module('Integration | Graph | Edges', function (hooks) { assert.deepEqual(state.remote, [identifier2], 'Our initial canonical state is correct'); assert.deepEqual(state.local, [identifier2], 'Our initial current state is correct'); - bestFriends.push({ data: [identifier2, identifier3] }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriends', + value: { data: [identifier2, identifier3] }, + }); + await settled(); state = stateOf(bestFriends); assert.deepEqual( @@ -274,7 +308,12 @@ module('Integration | Graph | Edges', function (hooks) { ); const identifier4 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '4' }); - bestFriends.addRecordData(identifier4); + graph.update({ + op: 'addToRelatedRecords', + record: identifier, + field: 'bestFriends', + value: identifier4, + }); state = stateOf(bestFriends); assert.deepEqual(state.remote, [identifier2, identifier3], 'Our canonical state is correct after local update'); @@ -339,7 +378,13 @@ module('Integration | Graph | Edges', function (hooks) { assert.deepEqual(state.remote, [identifier2], 'Our initial canonical state is correct'); assert.deepEqual(state.local, [identifier2], 'Our initial current state is correct'); - bestFriends.push({ data: [identifier2, identifier3] }); + graph.push({ + op: 'updateRelationship', + record: identifier, + field: 'bestFriends', + value: { data: [identifier2, identifier3] }, + }); + await settled(); state = stateOf(bestFriends); assert.deepEqual( @@ -355,7 +400,12 @@ module('Integration | Graph | Edges', function (hooks) { ); const identifier4 = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '4' }); - bestFriends.addRecordData(identifier4); + graph.update({ + op: 'addToRelatedRecords', + record: identifier, + field: 'bestFriends', + value: identifier4, + }); state = stateOf(bestFriends); assert.deepEqual(state.remote, [identifier2, identifier3], 'Our canonical state is correct after local update'); diff --git a/packages/store/addon/-debug/index.js b/packages/store/addon/-debug/index.js index 0c8eae73f22..929fc86626f 100644 --- a/packages/store/addon/-debug/index.js +++ b/packages/store/addon/-debug/index.js @@ -35,18 +35,14 @@ if (DEBUG) { return addedModelClass.prototype instanceof modelClass || modelClass.detect(addedModelClass); }; - assertPolymorphicType = function assertPolymorphicType( - parentInternalModel, - relationshipMeta, - addedInternalModel, - store - ) { - let addedModelName = addedInternalModel.modelName; - let parentModelName = parentInternalModel.modelName; - let key = relationshipMeta.key; - let relationshipModelName = relationshipMeta.type; + assertPolymorphicType = function assertPolymorphicType(parentIdentifier, parentDefinition, addedIdentifier, store) { + store = store._store ? store._store : store; // allow usage with storeWrapper + let addedModelName = addedIdentifier.type; + let parentModelName = parentIdentifier.type; + let key = parentDefinition.key; + let relationshipModelName = parentDefinition.type; let relationshipClass = store.modelFor(relationshipModelName); - let addedClass = store.modelFor(addedInternalModel.modelName); + let addedClass = store.modelFor(addedModelName); let assertionMessage = `The '${addedModelName}' type does not implement '${relationshipModelName}' and thus cannot be assigned to the '${key}' relationship in '${parentModelName}'. Make it a descendant of '${relationshipModelName}' or use a mixin of the same name.`; let isPolymorphic = checkPolymorphic(relationshipClass, addedClass); diff --git a/packages/store/addon/-private/identifiers/cache.ts b/packages/store/addon/-private/identifiers/cache.ts index 1dd4d6f3127..251cbe6a539 100644 --- a/packages/store/addon/-private/identifiers/cache.ts +++ b/packages/store/addon/-private/identifiers/cache.ts @@ -74,7 +74,7 @@ function defaultGenerationMethod(data: ResourceIdentifierObject, bucket: string) return data.lid; } let { type, id } = data; - if (isNonEmptyString(id)) { + if (isNonEmptyString(coerceId(id))) { return `@ember-data:lid-${normalizeModelName(type)}-${id}`; } return uuidv4(); diff --git a/packages/store/addon/-private/system/backburner.js b/packages/store/addon/-private/system/backburner.js index b5c9252bbe8..6aa30ddf1a6 100644 --- a/packages/store/addon/-private/system/backburner.js +++ b/packages/store/addon/-private/system/backburner.js @@ -8,11 +8,13 @@ import Ember from 'ember'; // TODO: expose Ember._Backburner as `import { _Backburner } from '@ember/runloop'` in ember-rfc176-data + emberjs/ember.js /* - flushRelationships is used by the Graph to batch updates syncRelationships is used by the UI to grab updates from the graph and update the ManyArrays. + + We may be able to remove this once the new relationship layer is + complete. */ -const backburner = new Ember._Backburner(['flushRelationships', 'syncRelationships']); +const backburner = new Ember._Backburner(['coalesce', 'sync', 'notify']); if (DEBUG) { registerWaiter(() => { diff --git a/packages/store/addon/-private/system/core-store.ts b/packages/store/addon/-private/system/core-store.ts index 77d9b9252d5..2b2b51d1c56 100644 --- a/packages/store/addon/-private/system/core-store.ts +++ b/packages/store/addon/-private/system/core-store.ts @@ -26,7 +26,6 @@ import { import { HAS_ADAPTER_PACKAGE, HAS_EMBER_DATA_PACKAGE, - HAS_MODEL_PACKAGE, HAS_RECORD_DATA_PACKAGE, HAS_SERIALIZER_PACKAGE, } from '@ember-data/private-build-infra'; @@ -63,9 +62,6 @@ import { internalModelFactoryFor, recordIdentifierFor, setRecordIdentifier } fro import RecordDataStoreWrapper from './store/record-data-store-wrapper'; import { normalizeResponseHelper } from './store/serializer-response'; -type BelongsToRelationship = import('@ember-data/record-data/-private').BelongsToRelationship; -type ManyRelationship = import('@ember-data/record-data/-private').ManyRelationship; - type RelationshipState = import('@ember-data/record-data/-private/graph/-state').RelationshipState; type ShimModelClass = import('./model/shim-model-class').default; type Snapshot = import('./snapshot').default; @@ -95,7 +91,6 @@ type RecordDataRecordWrapper = import('../ts-interfaces/record-data-record-wrapp type AttributesSchema = import('../ts-interfaces/record-data-schemas').AttributesSchema; type SchemaDefinitionService = import('../ts-interfaces/schema-definition-service').SchemaDefinitionService; type PrivateSnapshot = import('./snapshot').PrivateSnapshot; -type Relationship = import('@ember-data/record-data/-private').Relationship; type RecordDataClass = typeof import('@ember-data/record-data/-private').RecordData; type RequestCache = import('./request-cache').default; @@ -115,17 +110,8 @@ type PendingSaveItem = { resolver: RSVP.Deferred; }; -let _Model; - const RECORD_REFERENCES = new WeakMap(); -function getModel() { - if (HAS_MODEL_PACKAGE) { - _Model = _Model || require('@ember-data/model').default; - } - return _Model; -} - function freeze(obj: T): T { if (typeof Object.freeze === 'function') { return Object.freeze(obj); @@ -228,8 +214,6 @@ interface CoreStore { adapter: string; } -type RelationshipEdge = Relationship | BelongsToRelationship | ManyRelationship; - abstract class CoreStore extends Service { /** * EmberData specific backburner instance @@ -251,8 +235,6 @@ abstract class CoreStore extends Service { */ // used for coalescing record save requests private _pendingSave: PendingSaveItem[] = []; - // used for coalescing relationship updates - private _updatedRelationships: RelationshipEdge[] = []; // used for coalescing internal model updates private _updatedInternalModels: InternalModel[] = []; @@ -690,17 +672,17 @@ abstract class CoreStore extends Service { if (DEBUG) { assertDestroyingStore(this, 'deleteRecord'); } - if (CUSTOM_MODEL_CLASS) { - if (HAS_MODEL_PACKAGE && record instanceof getModel()) { - return record.deleteRecord(); - } else { + this._backburner.join(() => { + if (CUSTOM_MODEL_CLASS) { let identifier = recordIdentifierFor(record); let internalModel = internalModelFactoryFor(this).peek(identifier); - internalModel!.deleteRecord(); + if (internalModel) { + internalModel.deleteRecord(); + } + } else { + record.deleteRecord(); } - } else { - record.deleteRecord(); - } + }); } /** @@ -723,12 +705,10 @@ abstract class CoreStore extends Service { assertDestroyingStore(this, 'unloadRecord'); } if (CUSTOM_MODEL_CLASS) { - if (HAS_MODEL_PACKAGE && record instanceof getModel()) { - return record.unloadRecord(); - } else { - let identifier = recordIdentifierFor(record); - let internalModel = internalModelFactoryFor(this).peek(identifier); - internalModel!.unloadRecord(); + let identifier = recordIdentifierFor(record); + let internalModel = internalModelFactoryFor(this).peek(identifier); + if (internalModel) { + internalModel.unloadRecord(); } } else { record.unloadRecord(); @@ -1108,7 +1088,9 @@ abstract class CoreStore extends Service { _findByInternalModel(internalModel, options: { preload?: any } = {}) { if (options.preload) { - internalModel.preloadData(options.preload); + this._backburner.join(() => { + internalModel.preloadData(options.preload); + }); } let fetchedInternalModel = this._findEmptyInternalModel(internalModel, options); @@ -3571,6 +3553,14 @@ abstract class CoreStore extends Service { } } + if (HAS_RECORD_DATA_PACKAGE) { + const peekGraph = require('@ember-data/record-data/-private').peekGraph; + let graph = peekGraph(this); + if (graph) { + graph.destroy(); + } + } + return super.destroy(); } @@ -3580,15 +3570,19 @@ abstract class CoreStore extends Service { identifierCacheFor(this).destroy(); - this.unloadAll(); + // destroy the graph before unloadAll + // since then we avoid churning relationships + // during unload if (HAS_RECORD_DATA_PACKAGE) { const peekGraph = require('@ember-data/record-data/-private').peekGraph; let graph = peekGraph(this); if (graph) { - graph.destroy(); + graph.willDestroy(); } } + this.unloadAll(); + if (DEBUG) { unregisterWaiter(this.__asyncWaiter); let shouldTrack = this.shouldTrackAsyncRequests; @@ -3615,26 +3609,6 @@ abstract class CoreStore extends Service { } } - _updateRelationshipState(relationship: Relationship | BelongsToRelationship | ManyRelationship) { - if (this._updatedRelationships.push(relationship) !== 1) { - return; - } - - this._backburner.join(() => { - this._backburner.schedule('syncRelationships', this, this._flushUpdatedRelationships); - }); - } - - _flushUpdatedRelationships() { - let updated = this._updatedRelationships; - - for (let i = 0, l = updated.length; i < l; i++) { - updated[i].flushCanonical(); - } - - updated.length = 0; - } - _updateInternalModel(internalModel: InternalModel) { if (this._updatedInternalModels.push(internalModel) !== 1) { return; diff --git a/packages/store/addon/-private/system/fetch-manager.ts b/packages/store/addon/-private/system/fetch-manager.ts index db3b5a47282..0e91da72e76 100644 --- a/packages/store/addon/-private/system/fetch-manager.ts +++ b/packages/store/addon/-private/system/fetch-manager.ts @@ -189,7 +189,7 @@ export default class FetchManager { // We already have a pending fetch for this if (pendingFetches) { - let matchingPendingFetch = pendingFetches.find((fetch) => fetch.identifier.id === identifier.id); + let matchingPendingFetch = pendingFetches.filter((fetch) => fetch.identifier.id === identifier.id)[0]; if (matchingPendingFetch) { return matchingPendingFetch.resolver.promise; } diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 4d90ccbaf0c..7ed64ff5ac0 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -171,7 +171,6 @@ export default class InternalModel { this.__recordData = null; this._promiseProxy = null; - this._record = null; this._isDestroyed = false; this._doNotDestroy = false; this.isError = false; @@ -412,7 +411,11 @@ export default class InternalModel { // TODO IGOR add a test that fails when this is missing, something that involves canceling a destroy // and the destroy not happening, and then later on trying to destroy this._doNotDestroy = false; - + // this has to occur before the internal model is removed + // for legacy compat. + if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { + this.store.recordArrayManager.recordDidChange(this.identifier); + } if (this._record) { if (CUSTOM_MODEL_CLASS) { this.store.teardownRecord(this._record); @@ -434,22 +437,27 @@ export default class InternalModel { /* If the manyArray is for a sync relationship, we should clear it to preserve the semantics of client-side delete. - - It is likely in this case instead of retaining we should destroy - - @runspired */ - manyArray.clear(); + manyArray.retrieveLatest(); } }); } // move to an empty never-loaded state - this.store.recordArrayManager.recordDidChange(this.identifier); - this._recordData.unloadRecord(); + // ensure any record notifications happen prior to us + // unseting the record but after we've triggered + // destroy + this.store._backburner.join(() => { + this._recordData.unloadRecord(); + }); + this._record = null; this.isReloading = false; this.error = null; this.currentState = RootState.empty; + if (REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { + this.store.recordArrayManager.recordDidChange(this.identifier); + } } deleteRecord() { @@ -581,7 +589,7 @@ export default class InternalModel { // Unfortunately, some scenarios where that is not possible. Such as: // // ```js - // const record = store.find(‘record’, 1); + // const record = store.findRecord(‘record’, 1); // record.unloadRecord(); // store.createRecord(‘record’, 1); // ``` @@ -791,8 +799,8 @@ export default class InternalModel { let jsonApi = (this._recordData as DefaultRecordData).getHasMany(key); // TODO move this to a public api if (jsonApi._relationship) { - jsonApi._relationship.setHasFailedLoadAttempt(false); - jsonApi._relationship.setShouldForceReload(true); + jsonApi._relationship.state.hasFailedLoadAttempt = false; + jsonApi._relationship.state.shouldForceReload = true; } let relationshipMeta = this.store._relationshipMetaFor(this.modelName, null, key); let manyArray = this.getManyArray(key); @@ -814,8 +822,8 @@ export default class InternalModel { let resource = (this._recordData as DefaultRecordData).getBelongsTo(key); // TODO move this to a public api if (resource._relationship) { - resource._relationship.setHasFailedLoadAttempt(false); - resource._relationship.setShouldForceReload(true); + resource._relationship.state.hasFailedLoadAttempt = false; + resource._relationship.state.shouldForceReload = true; } let relationshipMeta = this.store._relationshipMetaFor(this.modelName, null, key); let promise = this._findBelongsTo(key, resource, relationshipMeta, options); @@ -988,15 +996,13 @@ export default class InternalModel { if (CUSTOM_MODEL_CLASS) { this.store._notificationManager.notify(this.identifier, 'relationships'); } else { - let manyArray = this._manyArrayCache[key]; + let manyArray = this._manyArrayCache[key] || this._retainedManyArrayCache[key]; if (manyArray) { // TODO: this will "resurrect" previously unloaded records // see test '1:many async unload many side' // in `tests/integration/records/unload-test.js` // probably we don't want to retrieve latest eagerly when notifyhasmany changed // but rather lazily when someone actually asks for a manyarray - // - // that said, also not clear why we haven't moved this to retainedmanyarray so maybe that's the bit that's just not working manyArray.retrieveLatest(); } } @@ -1072,16 +1078,18 @@ export default class InternalModel { } rollbackAttributes() { - let dirtyKeys = this._recordData.rollbackAttributes(); - if (get(this, 'isError')) { - this.didCleanError(); - } + this.store._backburner.join(() => { + let dirtyKeys = this._recordData.rollbackAttributes(); + if (get(this, 'isError')) { + this.didCleanError(); + } - this.send('rolledBack'); + this.send('rolledBack'); - if (this._record && dirtyKeys && dirtyKeys.length > 0) { - this._record._notifyProperties(dirtyKeys); - } + if (this._record && dirtyKeys && dirtyKeys.length > 0) { + this._record._notifyProperties(dirtyKeys); + } + }); } /* @@ -1192,7 +1200,9 @@ export default class InternalModel { removeFromInverseRelationships() { if (this.__recordData) { - this._recordData.removeFromInverseRelationships(); + this.store._backburner.join(() => { + this._recordData.removeFromInverseRelationships(); + }); } } @@ -1497,10 +1507,10 @@ if (!REMOVE_RECORD_ARRAY_MANAGER_LEGACY_COMPAT) { function handleCompletedRelationshipRequest(internalModel, key, relationship, value, error) { delete internalModel._relationshipPromisesCache[key]; - relationship.setShouldForceReload(false); + relationship.state.shouldForceReload = false; if (error) { - relationship.setHasFailedLoadAttempt(true); + relationship.state.hasFailedLoadAttempt = true; let proxy = internalModel._relationshipProxyCache[key]; // belongsTo relationships are sometimes unloaded // when a load fails, in this case we need @@ -1518,9 +1528,9 @@ function handleCompletedRelationshipRequest(internalModel, key, relationship, va throw error; } - relationship.setHasFailedLoadAttempt(false); + relationship.state.hasFailedLoadAttempt = false; // only set to not stale if no error is thrown - relationship.setRelationshipIsStale(false); + relationship.state.isStale = false; return value; } diff --git a/packages/store/addon/-private/system/model/notify-changes.ts b/packages/store/addon/-private/system/model/notify-changes.ts index 28ff3d8a188..641aee36171 100644 --- a/packages/store/addon/-private/system/model/notify-changes.ts +++ b/packages/store/addon/-private/system/model/notify-changes.ts @@ -24,12 +24,14 @@ export default function notifyChanges( if (meta.kind === 'belongsTo') { record.notifyPropertyChange(key); } else if (meta.kind === 'hasMany') { + let didRemoveUnloadedModel = false; if (meta.options.async) { record.notifyPropertyChange(key); - internalModel.hasManyRemovalCheck(key); + didRemoveUnloadedModel = internalModel.hasManyRemovalCheck(key); } - if (internalModel._manyArrayCache[key]) { - internalModel._manyArrayCache[key].retrieveLatest(); + let manyArray = internalModel._manyArrayCache[key] || internalModel._retainedManyArrayCache[key]; + if (manyArray && !didRemoveUnloadedModel) { + manyArray.retrieveLatest(); } } }); diff --git a/packages/store/addon/-private/system/references/belongs-to.js b/packages/store/addon/-private/system/references/belongs-to.js index c6d29dd786e..88afe4d53b8 100644 --- a/packages/store/addon/-private/system/references/belongs-to.js +++ b/packages/store/addon/-private/system/references/belongs-to.js @@ -6,7 +6,7 @@ import { DEPRECATE_BELONGS_TO_REFERENCE_PUSH } from '@ember-data/private-build-i import { assertPolymorphicType } from '@ember-data/store/-debug'; import { internalModelFactoryFor, peekRecordIdentifier, recordIdentifierFor } from '../store/internal-model-factory'; -import Reference, { internalModelForReference } from './reference'; +import Reference from './reference'; /** @module @ember-data/store @@ -150,14 +150,21 @@ export default class BelongsToReference extends Reference { } assertPolymorphicType( - internalModelForReference(this), + this.belongsToRelationship.identifier, this.belongsToRelationship.definition, - record._internalModel, + record._internalModel.identifier, this.store ); - //TODO Igor cleanup, maybe move to relationship push - this.belongsToRelationship.updateData(recordIdentifierFor(record)); + const { graph, identifier } = this.belongsToRelationship; + this.store._backburner.join(() => { + graph.push({ + op: 'replaceRelatedRecord', + record: identifier, + field: this.key, + value: recordIdentifierFor(record), + }); + }); return record; }); diff --git a/packages/store/addon/-private/system/references/has-many.js b/packages/store/addon/-private/system/references/has-many.js index f5d5eb3d539..4afc714797d 100644 --- a/packages/store/addon/-private/system/references/has-many.js +++ b/packages/store/addon/-private/system/references/has-many.js @@ -188,14 +188,27 @@ export default class HasManyReference extends Reference { if (DEBUG) { let relationshipMeta = this.hasManyRelationship.definition; - assertPolymorphicType(internalModel, relationshipMeta, record._internalModel, this.store); + assertPolymorphicType( + internalModel.identifier, + relationshipMeta, + record._internalModel.identifier, + this.store + ); } return recordIdentifierFor(record); }); - this.hasManyRelationship.computeChanges(identifiers); + const { graph, identifier } = this.hasManyRelationship; + this.store._backburner.join(() => { + graph.push({ + op: 'replaceRelatedRecords', + record: identifier, + field: this.key, + value: identifiers, + }); + }); - return internalModel.getHasMany(this.hasManyRelationship.definition.key); + return internalModel.getHasMany(this.key); // TODO IGOR it seems wrong that we were returning the many array here //return this.hasManyRelationship.manyArray; }); @@ -207,7 +220,7 @@ export default class HasManyReference extends Reference { return false; } - let members = this.hasManyRelationship.members.toArray(); + let members = this.hasManyRelationship.currentState; //TODO Igor cleanup return members.every((identifier) => { diff --git a/packages/store/addon/-private/system/store/finders.js b/packages/store/addon/-private/system/store/finders.js index 1a7537fac60..daaaa5b3cc4 100644 --- a/packages/store/addon/-private/system/store/finders.js +++ b/packages/store/addon/-private/system/store/finders.js @@ -261,7 +261,15 @@ function fixRelationshipData(relationshipData, relationshipKind, { id, modelName if (relationshipKind === 'hasMany') { payload = relationshipData || []; - payload.push(parentRelationshipData); + if (relationshipData) { + if ( + !relationshipData.filter((v) => v.type === parentRelationshipData.type && v.id === parentRelationshipData.id)[0] + ) { + payload.push(parentRelationshipData); + } + } else { + payload.push(parentRelationshipData); + } } else { payload = relationshipData || {}; assign(payload, parentRelationshipData); diff --git a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts index f70cbaa1332..6ffb09b68a8 100644 --- a/packages/store/addon/-private/system/store/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/system/store/record-data-store-wrapper.ts @@ -18,8 +18,6 @@ type RelationshipDefinition = import('@ember-data/model/-private/system/relation @module @ember-data/store */ -type StableIdentifierOrString = StableRecordIdentifier | string; - function metaIsRelationshipDefinition(meta: RelationshipSchema): meta is RelationshipDefinition { return typeof (meta as RelationshipDefinition)._inverseKey === 'function'; } @@ -37,11 +35,11 @@ export default class RecordDataStoreWrapper implements StoreWrapper { /** * @internal */ - declare _willUpdateManyArrays: boolean; + declare _willNotify: boolean; /** * @internal */ - declare _pendingManyArrayUpdates: StableIdentifierOrString[]; + declare _pendingNotifies: Map>; /** * @internal */ @@ -49,8 +47,8 @@ export default class RecordDataStoreWrapper implements StoreWrapper { constructor(_store: CoreStore) { this._store = _store; - this._willUpdateManyArrays = false; - this._pendingManyArrayUpdates = []; + this._willNotify = false; + this._pendingNotifies = new Map(); } get identifierCache(): IdentifierCache { @@ -70,20 +68,23 @@ export default class RecordDataStoreWrapper implements StoreWrapper { /** * @internal */ - _scheduleManyArrayUpdate(identifier: StableRecordIdentifier, key: string) { - let pending = (this._pendingManyArrayUpdates = this._pendingManyArrayUpdates || []); - pending.push(identifier, key); + _scheduleNotification(identifier: StableRecordIdentifier, key: string, kind: 'belongsTo' | 'hasMany') { + let pending = this._pendingNotifies.get(identifier); + + if (!pending) { + pending = new Map(); + this._pendingNotifies.set(identifier, pending); + } + pending.set(key, kind); - if (this._willUpdateManyArrays === true) { + if (this._willNotify === true) { return; } - this._willUpdateManyArrays = true; + this._willNotify = true; let backburner: any = this._store._backburner; - backburner.join(() => { - backburner.schedule('syncRelationships', this, this._flushPendingManyArrayUpdates); - }); + backburner.schedule('notify', this, this._flushNotifications); } notifyErrorsChange(type: string, id: string, lid: string | null): void; @@ -99,25 +100,28 @@ export default class RecordDataStoreWrapper implements StoreWrapper { } } - _flushPendingManyArrayUpdates(): void { - if (this._willUpdateManyArrays === false) { + _flushNotifications(): void { + if (this._willNotify === false) { return; } - let pending = this._pendingManyArrayUpdates; - this._pendingManyArrayUpdates = []; - this._willUpdateManyArrays = false; + let pending = this._pendingNotifies; + this._pendingNotifies = new Map(); + this._willNotify = false; const factory = internalModelFactoryFor(this._store); - for (let i = 0; i < pending.length; i += 2) { - let identifier = pending[i] as StableRecordIdentifier; - let key = pending[i + 1] as string; - let internalModel = factory.peek(identifier); - + pending.forEach((map, identifier) => { + const internalModel = factory.peek(identifier); if (internalModel) { - internalModel.notifyHasManyChange(key); + map.forEach((kind, key) => { + if (kind === 'belongsTo') { + internalModel.notifyBelongsToChange(key); + } else { + internalModel.notifyHasManyChange(key); + } + }); } - } + }); } attributesDefinitionFor(type: string): AttributesSchema { @@ -190,7 +194,7 @@ export default class RecordDataStoreWrapper implements StoreWrapper { notifyHasManyChange(type: string, id: string | null, lid: string | null | undefined, key: string): void { const resource = constructResource(type, id, lid); const identifier = identifierCacheFor(this._store).getOrCreateRecordIdentifier(resource); - this._scheduleManyArrayUpdate(identifier, key); + this._scheduleNotification(identifier, key, 'hasMany'); } notifyBelongsToChange(type: string, id: string | null, lid: string, key: string): void; @@ -198,11 +202,8 @@ export default class RecordDataStoreWrapper implements StoreWrapper { notifyBelongsToChange(type: string, id: string | null, lid: string | null | undefined, key: string): void { const resource = constructResource(type, id, lid); const identifier = identifierCacheFor(this._store).getOrCreateRecordIdentifier(resource); - let internalModel = internalModelFactoryFor(this._store).peek(identifier); - if (internalModel) { - internalModel.notifyBelongsToChange(key); - } + this._scheduleNotification(identifier, key, 'belongsTo'); } notifyStateChange(type: string, id: string, lid: string | null, key?: string): void;