diff --git a/.eslintrc.js b/.eslintrc.js index 97b43305a6f..229dfacc9ca 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -204,6 +204,8 @@ module.exports = { 'packages/store/addon/-private/system/snapshot-record-array.ts', 'packages/store/addon/-private/system/schema-definition-service.ts', 'packages/store/addon/-private/system/request-cache.ts', + 'packages/store/addon/-private/system/references/belongs-to.ts', + 'packages/store/addon/-private/system/references/has-many.ts', 'packages/store/addon/-private/system/references/reference.ts', 'packages/store/addon/-private/system/references/record.ts', 'packages/store/addon/-private/system/record-notification-manager.ts', diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8a665586bb7..752c1ea43b4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -313,10 +313,10 @@ jobs: matrix: partner: [ - ember-data-relationship-tracker, + # ember-data-relationship-tracker, ember-m3, ember-observer, - ember-resource-metadata, + # ember-resource-metadata, factory-guy, ilios-frontend, model-fragments, diff --git a/packages/-ember-data/tests/acceptance/relationships/tracking-record-state-test.js b/packages/-ember-data/tests/acceptance/relationships/tracking-record-state-test.js index a3b249660ce..537f2b44187 100644 --- a/packages/-ember-data/tests/acceptance/relationships/tracking-record-state-test.js +++ b/packages/-ember-data/tests/acceptance/relationships/tracking-record-state-test.js @@ -34,7 +34,7 @@ module('tracking state flags on a record', function (hooks) { enumerable: true, configurable: true, get() { - tag.reg; // subscribe + tag.rev; // subscribe if (_isDirty && !_isUpdating) { _isUpdating = true; resolve(desc.get.call(this)).then((v) => { diff --git a/packages/-ember-data/tests/integration/identifiers/lid-reflection-test.ts b/packages/-ember-data/tests/integration/identifiers/lid-reflection-test.ts index 50906df1767..561863d01da 100644 --- a/packages/-ember-data/tests/integration/identifiers/lid-reflection-test.ts +++ b/packages/-ember-data/tests/integration/identifiers/lid-reflection-test.ts @@ -169,11 +169,13 @@ module('Integration | Identifiers - lid reflection', function (hooks) { class TestAdapter extends Adapter { createRecord(store, ModelClass, snapshot) { - const lid = recordIdentifierFor(snapshot.record.ingredients.firstObject).lid; + const cakeLid = recordIdentifierFor(snapshot.record).lid; + const ingredientLid = recordIdentifierFor(snapshot.record.ingredients.firstObject).lid; return resolve({ data: { type: 'cake', id: '1', + lid: cakeLid, attributes: { name: 'Cheesecake', }, @@ -183,7 +185,7 @@ module('Integration | Identifiers - lid reflection', function (hooks) { { type: 'ingredient', id: '2', - lid, + lid: ingredientLid, }, ], }, @@ -193,7 +195,7 @@ module('Integration | Identifiers - lid reflection', function (hooks) { { type: 'ingredient', id: '2', - lid, + lid: ingredientLid, attributes: { name: 'Cheese', }, @@ -202,6 +204,7 @@ module('Integration | Identifiers - lid reflection', function (hooks) { data: { type: 'cake', id: '1', + lid: cakeLid, }, }, }, @@ -216,10 +219,17 @@ module('Integration | Identifiers - lid reflection', function (hooks) { const cheese = store.createRecord('ingredient', { name: 'Cheese' }); const cake = store.createRecord('cake', { name: 'Cheesecake', ingredients: [cheese] }); + // Consume ids before save() to check for update errors + assert.strictEqual(cake.id, null, 'cake id is initially null'); + assert.strictEqual(cheese.id, null, 'cheese id is initially null'); + await cake.save(); assert.deepEqual(cake.hasMany('ingredients').ids(), ['2']); - assert.equal(cake.ingredients.objectAt(0).name, 'Cheese'); + assert.strictEqual(cake.ingredients.objectAt(0).name, 'Cheese'); + + assert.strictEqual(cake.id, '1', 'cake has the correct id'); + assert.strictEqual(cheese.id, '2', 'cheese has the correct id'); }); test('belongsTo() has correct state after .save() on a newly created record with sideposted child record when lid is provided in the response payload', async function (assert) { 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 a009d294281..e142412340a 100644 --- a/packages/-ember-data/tests/integration/records/create-record-test.js +++ b/packages/-ember-data/tests/integration/records/create-record-test.js @@ -38,6 +38,25 @@ module('Store.createRecord() coverage', function (hooks) { store = owner.lookup('service:store'); }); + test("createRecord doesn't crash when setter is involved", async function (assert) { + class User extends Model { + @attr() email; + + get name() { + return this.email ? this.email.substring(0, this.email.indexOf('@')) : ''; + } + + set name(value) { + this.email = `${value.toLowerCase()}@ember.js`; + } + } + this.owner.register(`model:user`, User); + const store = this.owner.lookup('service:store'); + + const user = store.createRecord('user', { name: 'Robert' }); + assert.strictEqual(user.email, 'robert@ember.js'); + }); + test('unloading a newly created a record with a sync belongsTo relationship', async function (assert) { let chris = store.push({ data: { diff --git a/packages/-ember-data/tests/integration/records/relationship-changes-test.js b/packages/-ember-data/tests/integration/records/relationship-changes-test.js index 7b9c6272788..acd47c0294e 100644 --- a/packages/-ember-data/tests/integration/records/relationship-changes-test.js +++ b/packages/-ember-data/tests/integration/records/relationship-changes-test.js @@ -602,14 +602,16 @@ module('integration/records/relationship-changes - Relationship changes', functi }); let person = store.peekRecord('person', 'wat'); - let siblings = person.get('siblings'); + let siblingsPromise = person.siblings; + + await siblingsPromise; // flush initial state since // nothing is consuming us. // else the test will fail because we will // (correctly) not notify the array observer // as there is still a pending notification - siblings.length; + siblingsPromise.length; let observer = { arrayWillChange(array, start, removing, adding) { @@ -627,7 +629,7 @@ module('integration/records/relationship-changes - Relationship changes', functi }, }; - siblings.addArrayObserver(observer); + siblingsPromise.addArrayObserver(observer); store.push({ data: { @@ -646,7 +648,7 @@ module('integration/records/relationship-changes - Relationship changes', functi assert.equal(willChangeCount, 1, 'willChange observer should be triggered once'); assert.equal(didChangeCount, 1, 'didChange observer should be triggered once'); - siblings.removeArrayObserver(observer); + siblingsPromise.removeArrayObserver(observer); }, { id: 'array-observers', count: 2, when: { ember: '>=3.26.0' } } ); diff --git a/packages/-ember-data/tests/integration/references/autotracking-test.js b/packages/-ember-data/tests/integration/references/autotracking-test.js new file mode 100644 index 00000000000..b14791c09ea --- /dev/null +++ b/packages/-ember-data/tests/integration/references/autotracking-test.js @@ -0,0 +1,154 @@ +import EmberObject from '@ember/object'; +import { getRootElement, render } from '@ember/test-helpers'; +import settled from '@ember/test-helpers/settled'; + +import hbs from 'htmlbars-inline-precompile'; +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; +import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; +import { recordIdentifierFor } from '@ember-data/store'; + +if (CUSTOM_MODEL_CLASS) { + module('integration/references/autotracking', function (hooks) { + setupRenderingTest(hooks); + + class User extends Model { + @attr name; + @belongsTo('user', { inverse: null, async: false }) + bestFriend; + @hasMany('user', { inverse: null, async: false }) + friends; + } + + let store, user; + hooks.beforeEach(function () { + const { owner } = this; + owner.register('model:user', User); + store = owner.lookup('service:store'); + + owner.register( + 'adapter:user', + class extends EmberObject { + createRecord() { + return { data: { id: '6', type: 'user' } }; + } + } + ); + owner.register( + 'serializer:user', + class extends EmberObject { + normalizeResponse(_, __, data) { + return data; + } + } + ); + + user = store.push({ + data: { + type: 'user', + id: '1', + attributes: { + name: 'Chris', + }, + relationships: { + bestFriend: { + data: { type: 'user', id: '2' }, + }, + friends: { + data: [{ type: 'user', id: '2' }], + }, + }, + }, + included: [ + { type: 'user', id: '2', attributes: { name: 'Igor' } }, + { type: 'user', id: '3', attributes: { name: 'David' } }, + { type: 'user', id: '4', attributes: { name: 'Scott' } }, + { type: 'user', id: '5', attributes: { name: 'Rob' } }, + ], + }); + }); + + test('BelongsToReference.id() is autotracked', async function (assert) { + class TestContext { + user = user; + + get bestFriendId() { + return this.user.belongsTo('bestFriend').id(); + } + } + + const testContext = new TestContext(); + this.set('context', testContext); + await render(hbs`id: {{if this.context.bestFriendId this.context.bestFriendId 'null'}}`); + + assert.strictEqual(getRootElement().textContent, 'id: 2', 'the id is initially correct'); + assert.strictEqual(testContext.bestFriendId, '2', 'the id is initially correct'); + user.bestFriend = store.createRecord('user', { name: 'Bill' }); + await settled(); + assert.strictEqual(getRootElement().textContent, 'id: null', 'the id updates to null'); + assert.strictEqual(testContext.bestFriendId, null, 'the id is correct when we swap records'); + await user.bestFriend.save(); + await settled(); + assert.strictEqual(getRootElement().textContent, 'id: 6', 'the id updates when the related record id updates'); + assert.strictEqual(testContext.bestFriendId, '6', 'the id is correct when the record is saved'); + }); + + test('HasManyReference.ids() is autotracked', async function (assert) { + class TestContext { + user = user; + + get friendIds() { + return this.user.hasMany('friends').ids(); + } + } + const testContext = new TestContext(); + this.set('context', testContext); + await render(hbs`{{#each this.context.friendIds as |id|}}id: {{if id id 'null'}}, {{/each}}`); + + assert.strictEqual(getRootElement().textContent, 'id: 2, ', 'the ids are initially correct'); + assert.deepEqual(testContext.friendIds, ['2'], 'the ids are initially correct'); + const bill = store.createRecord('user', { name: 'Bill' }); + user.friends.pushObject(bill); + await settled(); + assert.strictEqual(getRootElement().textContent, 'id: 2, id: null, ', 'the id is added for the new record'); + assert.deepEqual(testContext.friendIds, ['2', null], 'the ids are correct when we add a new record'); + await bill.save(); + await settled(); + assert.strictEqual( + getRootElement().textContent, + 'id: 2, id: 6, ', + 'the id updates when the related record id updates' + ); + assert.deepEqual(testContext.friendIds, ['2', '6'], 'the ids are correct when the new record is saved'); + }); + + test('RecordReference.id() is autotracked', async function (assert) { + const dan = store.createRecord('user', { name: 'Dan' }); + const identifier = recordIdentifierFor(dan); + const reference = store.getReference(identifier); + + class TestContext { + user = reference; + + get id() { + return this.user.id(); + } + } + + const testContext = new TestContext(); + this.set('context', testContext); + + await render(hbs`id: {{if this.context.id this.context.id 'null'}}`); + + assert.strictEqual(getRootElement().textContent, 'id: null', 'the id is null'); + assert.strictEqual(testContext.id, null, 'the id is correct initially'); + await dan.save(); + await settled(); + assert.strictEqual(getRootElement().textContent, 'id: 6', 'the id updates when the record id updates'); + assert.strictEqual(testContext.id, '6', 'the id is correct when the record is saved'); + }); + }); +} diff --git a/packages/-ember-data/tests/integration/references/has-many-test.js b/packages/-ember-data/tests/integration/references/has-many-test.js index d8169b7777b..70b24c1311d 100755 --- a/packages/-ember-data/tests/integration/references/has-many-test.js +++ b/packages/-ember-data/tests/integration/references/has-many-test.js @@ -361,27 +361,18 @@ module('integration/references/has-many', function (hooks) { }); }); - testInDebug('push(array) asserts polymorphic type', function (assert) { + testInDebug('push(array) asserts polymorphic type', async function (assert) { let store = this.owner.lookup('service:store'); - - var family; - run(function () { - family = store.push({ - data: { - type: 'family', - id: 1, - }, - }); + let family = store.push({ + data: { + type: 'family', + id: 1, + }, }); + let personsReference = family.hasMany('persons'); - var personsReference = family.hasMany('persons'); - - assert.expectAssertion(() => { - run(() => { - var data = [{ data: { type: 'family', id: 1 } }]; - - personsReference.push(data); - }); + assert.expectAssertion(async () => { + await personsReference.push([{ data: { type: 'family', id: '1' } }]); }, "The 'family' type does not implement 'person' and thus cannot be assigned to the 'persons' relationship in 'family'. Make it a descendant of 'person' or use a mixin of the same name."); }); @@ -429,56 +420,77 @@ module('integration/references/has-many', function (hooks) { }); }); - test('push(promise)', function (assert) { - var done = assert.async(); - - let store = this.owner.lookup('service:store'); + test('push(promise)', async function (assert) { + const store = this.owner.lookup('service:store'); + const deferred = defer(); - var push; - var deferred = defer(); - - run(function () { - var family = store.push({ - data: { - type: 'family', - id: 1, - relationships: { - persons: { - data: [ - { type: 'person', id: 1 }, - { type: 'person', id: 2 }, - ], - }, + const family = store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 }, + ], }, }, - }); - var personsReference = family.hasMany('persons'); - push = personsReference.push(deferred.promise); + }, }); + const personsReference = family.hasMany('persons'); + let pushResult = personsReference.push(deferred.promise); - assert.ok(push.then, 'HasManyReference.push returns a promise'); + assert.ok(pushResult.then, 'HasManyReference.push returns a promise'); - run(function () { - var payload = { - data: [ - { data: { type: 'person', id: 1, attributes: { name: 'Vito' } } }, - { data: { type: 'person', id: 2, attributes: { name: 'Michael' } } }, - ], - }; + const payload = { + data: [ + { data: { type: 'person', id: 1, attributes: { name: 'Vito' } } }, + { data: { type: 'person', id: 2, attributes: { name: 'Michael' } } }, + ], + }; - deferred.resolve(payload); - }); + deferred.resolve(payload); - run(function () { - push.then(function (records) { - assert.ok(records instanceof DS.ManyArray, 'push resolves with the referenced records'); - assert.equal(get(records, 'length'), 2); - assert.equal(records.objectAt(0).get('name'), 'Vito'); - assert.equal(records.objectAt(1).get('name'), 'Michael'); + const records = await pushResult; + assert.ok(records instanceof DS.ManyArray, 'push resolves with the referenced records'); + assert.strictEqual(get(records, 'length'), 2); + assert.strictEqual(records.objectAt(0).get('name'), 'Vito'); + assert.strictEqual(records.objectAt(1).get('name'), 'Michael'); + }); - done(); - }); + test('push valid json:api', async function (assert) { + const store = this.owner.lookup('service:store'); + + const family = store.push({ + data: { + type: 'family', + id: 1, + relationships: { + persons: { + data: [ + { type: 'person', id: 1 }, + { type: 'person', id: 2 }, + ], + }, + }, + }, }); + const personsReference = family.hasMany('persons'); + const payload = { + data: [ + { type: 'person', id: 1, attributes: { name: 'Vito' } }, + { type: 'person', id: 2, attributes: { name: 'Michael' } }, + ], + }; + const pushResult = personsReference.push(payload); + assert.ok(pushResult.then, 'HasManyReference.push returns a promise'); + + const records = await pushResult; + assert.ok(records instanceof DS.ManyArray, 'push resolves with the referenced records'); + assert.strictEqual(get(records, 'length'), 2); + assert.strictEqual(records.objectAt(0).get('name'), 'Vito'); + assert.strictEqual(records.objectAt(1).get('name'), 'Michael'); }); test('value() returns null when reference is not yet loaded', function (assert) { 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 4cb73afbecd..6790646389a 100644 --- a/packages/-ember-data/tests/integration/relationships/has-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/has-many-test.js @@ -849,7 +849,7 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }); }); - test('A hasMany relationship can be reloaded if it was fetched via a link', function (assert) { + test('A hasMany relationship can be reloaded if it was fetched via a link', async function (assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); @@ -889,35 +889,28 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }); }; - run(function () { - run(store, 'findRecord', 'post', 1) - .then(function (post) { - return post.get('comments'); - }) - .then(function (comments) { - assert.true(comments.get('isLoaded'), 'comments are loaded'); - assert.equal(comments.get('length'), 2, 'comments have 2 length'); + let post = await store.findRecord('post', 1); + let comments = await post.comments; + assert.true(comments.get('isLoaded'), 'comments are loaded'); + assert.strictEqual(comments.get('length'), 2, 'comments have 2 length'); - adapter.findHasMany = function (store, snapshot, link, relationship) { - assert.equal(relationship.type, 'comment', 'findHasMany relationship type was Comment'); - assert.equal(relationship.key, 'comments', 'findHasMany relationship key was comments'); - assert.equal(link, '/posts/1/comments', 'findHasMany link was /posts/1/comments'); + adapter.findHasMany = function (store, snapshot, link, relationship) { + assert.strictEqual(relationship.type, 'comment', 'findHasMany relationship type was Comment'); + assert.strictEqual(relationship.key, 'comments', 'findHasMany relationship key was comments'); + assert.strictEqual(link, '/posts/1/comments', 'findHasMany link was /posts/1/comments'); - return resolve({ - data: [ - { id: 1, type: 'comment', attributes: { body: 'First' } }, - { id: 2, type: 'comment', attributes: { body: 'Second' } }, - { id: 3, type: 'comment', attributes: { body: 'Thirds' } }, - ], - }); - }; + return resolve({ + data: [ + { id: 1, type: 'comment', attributes: { body: 'First' } }, + { id: 2, type: 'comment', attributes: { body: 'Second' } }, + { id: 3, type: 'comment', attributes: { body: 'Thirds' } }, + ], + }); + }; - return comments.reload(); - }) - .then(function (newComments) { - assert.equal(newComments.get('length'), 3, 'reloaded comments have 3 length'); - }); - }); + await comments.reload(); + + assert.strictEqual(comments.length, 3, 'reloaded comments have 3 length'); }); test('A sync hasMany relationship can be reloaded if it was fetched via ids', function (assert) { @@ -4098,4 +4091,50 @@ module('integration/relationships/has_many - Has-Many Relationships', function ( }); }); }); + + test('Pushing a relationship with duplicate identifiers results in a single entry for the record in the relationship', async function (assert) { + class PhoneUser extends Model { + @hasMany('phone-number', { async: false, inverse: null }) + phoneNumbers; + @attr name; + } + class PhoneNumber extends Model { + @attr number; + } + const { owner } = this; + + owner.register('model:phone-user', PhoneUser); + owner.register('model:phone-number', PhoneNumber); + + const store = owner.lookup('service:store'); + + store.push({ + data: { + id: 'call-me-anytime', + type: 'phone-number', + attributes: { + number: '1-800-DATA', + }, + }, + }); + + const person = store.push({ + data: { + id: '1', + type: 'phone-user', + attributes: {}, + relationships: { + phoneNumbers: { + data: [ + { type: 'phone-number', id: 'call-me-anytime' }, + { type: 'phone-number', id: 'call-me-anytime' }, + { type: 'phone-number', id: 'call-me-anytime' }, + ], + }, + }, + }, + }); + + assert.strictEqual(person.get('phoneNumbers.length'), 1); + }); }); diff --git a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js index 4924af96b97..77f2a3e49c4 100644 --- a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js @@ -1624,7 +1624,7 @@ module('integration/relationships/one_to_many_test - OneToMany relationships', f assert.equal(account.get('user'), null, 'Account does not have the user anymore'); }); - test('createRecord updates inverse record array which has observers', function (assert) { + test('createRecord updates inverse record array which has observers', async function (assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); @@ -1642,21 +1642,26 @@ module('integration/relationships/one_to_many_test - OneToMany relationships', f }; }; - return store.findAll('user').then((users) => { - assert.equal(users.get('length'), 1, 'Exactly 1 user'); + const users = await store.findAll('user'); + assert.strictEqual(users.get('length'), 1, 'Exactly 1 user'); - let user = users.get('firstObject'); - assert.equal(user.get('messages.length'), 0, 'Record array is initially empty'); + let user = users.get('firstObject'); + assert.strictEqual(user.get('messages.length'), 0, 'Record array is initially empty'); - // set up an observer - user.addObserver('messages.@each.title', () => {}); - user.get('messages.firstObject'); + // set up an observer + user.addObserver('messages.@each.title', () => {}); + user.get('messages.firstObject'); - let message = store.createRecord('message', { user, title: 'EmberFest was great' }); - assert.equal(user.get('messages.length'), 1, 'The message is added to the record array'); + const messages = await user.messages; - let messageFromArray = user.get('messages.firstObject'); - assert.ok(message === messageFromArray, 'Only one message record instance should be created'); - }); + assert.strictEqual(messages.length, 0, 'we have no messages'); + assert.strictEqual(user.messages.length, 0, 'we have no messages'); + + let message = store.createRecord('message', { user, title: 'EmberFest was great' }); + assert.strictEqual(messages.length, 1, 'The message is added to the record array'); + assert.strictEqual(user.messages.length, 1, 'The message is added to the record array'); + + let messageFromArray = user.messages.firstObject; + assert.true(message === messageFromArray, 'Only one message record instance should be created'); }); }); diff --git a/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js b/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js new file mode 100644 index 00000000000..7b58ab43340 --- /dev/null +++ b/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js @@ -0,0 +1,39 @@ +import { A } from '@ember/array'; +import { w } from '@ember/string'; + +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import Model, { attr, hasMany } from '@ember-data/model'; + +module('PromiseManyArray side-affected by EmberArray', (hooks) => { + setupRenderingTest(hooks); + + test('PromiseManyArray is not side-affected by EmberArray', async function (assert) { + const { owner } = this; + class Person extends Model { + @attr('string') name; + } + class Group extends Model { + @hasMany('person', { inverse: null }) members; + } + owner.register('model:person', Person); + owner.register('model:group', Group); + const store = owner.lookup('service:store'); + const members = w('Bob John Michael Larry Lucy').map((name) => store.createRecord('person', { name })); + const group = store.createRecord('group', { members }); + + const replaceFn = group.members.replace; + assert.strictEqual(group.members.length, 5, 'initial length is correct'); + + group.members.replace(0, 1); + assert.strictEqual(group.members.length, 4, 'updated length is correct'); + + A(group.members); + + assert.strictEqual(replaceFn, group.members.replace, 'we have the same function for replace'); + group.members.replace(0, 1); + assert.strictEqual(group.members.length, 3, 'updated length is correct'); + }); +}); 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 18fe25d7f30..a2018d0bef2 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 @@ -1,4 +1,4 @@ -import { get, observer } from '@ember/object'; +import EmberObject, { get, observer } from '@ember/object'; import { run } from '@ember/runloop'; import settled from '@ember/test-helpers/settled'; @@ -8,6 +8,7 @@ import { hash, Promise as EmberPromise } from 'rsvp'; import DS from 'ember-data'; import { setupTest } from 'ember-qunit'; +import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; import testInDebug from '@ember-data/unpublished-test-infra/test-support/test-in-debug'; import todo from '@ember-data/unpublished-test-infra/test-support/todo'; @@ -2393,6 +2394,237 @@ module('unit/model/relationships - DS.hasMany', function (hooks) { }); }); + test('findHasMany - can push the same record in twice and fetch the link', async function (assert) { + assert.expect(5); + const { owner } = this; + + owner.register( + 'adapter:post', + class extends EmberObject { + shouldBackgroundReloadRecord() { + return false; + } + async findHasMany() { + assert.ok(true, 'findHasMany called'); + return { + data: [ + { id: '1', type: 'comment', attributes: { name: 'FIRST' } }, + { id: '2', type: 'comment', attributes: { name: 'Rails is unagi' } }, + { id: '3', type: 'comment', attributes: { name: 'What is omakase?' } }, + ], + }; + } + } + ); + + owner.register( + 'model:post', + class extends Model { + @attr name; + @hasMany('comment', { async: true, inverse: null }) comments; + } + ); + owner.register( + 'model:comment', + class extends Model { + @attr name; + } + ); + + const store = owner.lookup('service:store'); + + // preload post:1 with a related link + store.push({ + data: { + type: 'post', + id: '1', + attributes: { + name: 'Rails is omakase', + }, + relationships: { + comments: { + links: { + related: '/posts/1/comments', + }, + }, + }, + }, + }); + + // update post:1 with same related link + store.push({ + data: { + type: 'post', + id: '1', + attributes: { + name: 'Rails is still omakase', + }, + relationships: { + comments: { + links: { + related: '/posts/1/comments', + }, + }, + }, + }, + }); + + let post = store.peekRecord('post', '1'); + + const promise = post.comments; + const promise2 = post.comments; + assert.strictEqual(promise, promise2, 'we return the same PromiseManyArray each time'); + const comments = await promise; + + assert.true(promise.isFulfilled, 'comments promise is fulfilled'); + assert.strictEqual(comments.length, 3, 'The correct records are in the array'); + const promise3 = post.comments; + assert.strictEqual(promise, promise3, 'we return the same PromiseManyArray each time'); + }); + + test('fetch records with chained async has-many, ensure the leafs are retrieved', async function (assert) { + assert.expect(8); + const { owner } = this; + + owner.register( + 'adapter:application', + class extends EmberObject { + coalesceFindRequests = true; + shouldBackgroundReloadRecord() { + return false; + } + async findRecord() { + assert.ok(true, 'findRecord called'); + return { + data: { + type: 'post-author', + id: '1', + relationships: { + posts: { + data: [ + { type: 'authored-post', id: '1' }, + { type: 'authored-post', id: '2' }, + ], + }, + }, + }, + }; + } + + async findMany() { + assert.ok(true, 'findMany called'); + return { + data: [ + { + type: 'authored-post', + id: '1', + attributes: { + name: 'A post', + }, + relationships: { + author: { + data: { type: 'post-author', id: '1' }, + }, + comments: { + links: { + related: './comments', + }, + }, + }, + }, + { + type: 'authored-post', + id: '2', + attributes: { + name: 'A second post', + }, + relationships: { + author: { + data: { type: 'post-author', id: '1' }, + }, + comments: { + links: { + related: './comments', + }, + }, + }, + }, + ], + }; + } + + async findHasMany() { + assert.ok('findHasMany called'); + return { + data: [ + { + type: 'post-comment', + id: '1', + attributes: { + body: 'Some weird words', + }, + }, + { + type: 'post-comment', + id: '2', + attributes: { + body: 'Some mean words', + }, + }, + { + type: 'post-comment', + id: '3', + attributes: { + body: 'Some kind words', + }, + }, + ], + }; + } + } + ); + + owner.register( + 'model:post-author', + class extends Model { + @attr name; + @hasMany('authored-post', { async: true, inverse: 'author' }) posts; + } + ); + owner.register( + 'model:authored-post', + class extends Model { + @attr name; + @belongsTo('post-author', { async: false, inverse: 'posts' }) author; + @hasMany('post-comment', { async: true, inverse: 'post' }) comments; + } + ); + owner.register( + 'model:post-comment', + class extends Model { + @attr body; + @belongsTo('authored-post', { async: true, inverse: 'comments' }) post; + } + ); + + const store = owner.lookup('service:store'); + + const user = await store.findRecord('post-author', '1'); + const posts = await user.posts; + assert.strictEqual(posts.length, 2, 'we loaded two posts'); + const firstPost = posts.objectAt(0); + const firstPostCommentsPromise = firstPost.comments; + const originalPromise = firstPostCommentsPromise.promise; + firstPost.comments; // trigger an extra access + const firstPostComments = await firstPostCommentsPromise; + firstPost.comments; // trigger an extra access + assert.true(firstPostCommentsPromise.isFulfilled, 'comments relationship is fulfilled'); + assert.true(firstPostCommentsPromise.promise === originalPromise, 'we did not re-trigger the property'); + assert.strictEqual(firstPostComments.length, 3, 'we loaded three comments'); + firstPost.comments; // trigger an extra access + assert.true(firstPostCommentsPromise.isFulfilled, 'comments relationship is fulfilled'); + }); + test('DS.ManyArray is lazy', async function (assert) { let peopleDidChange = 0; const Tag = DS.Model.extend({ diff --git a/packages/model/addon/-private/has-many.js b/packages/model/addon/-private/has-many.js index 1c5fafce34e..3df55c7b54c 100644 --- a/packages/model/addon/-private/has-many.js +++ b/packages/model/addon/-private/has-many.js @@ -24,17 +24,17 @@ import { computedMacroWithOptionalParams } from './util'; ```app/models/post.js import Model, { hasMany } from '@ember-data/model'; - + export default class PostModel extends Model { - @hasMany('comment') comments; + @hasMany('comment') comments; } ``` ```app/models/comment.js import Model, { belongsTo } from '@ember-data/model'; - + export default class CommentModel extends Model { - @belongsTo('post') post; + @belongsTo('post') post; } ``` @@ -54,7 +54,7 @@ import { computedMacroWithOptionalParams } from './util'; import Model, { hasMany } from '@ember-data/model'; export default class TagModel extends Model { - @hasMany('post') posts; + @hasMany('post') posts; } ``` diff --git a/packages/model/addon/-private/model.js b/packages/model/addon/-private/model.js index 009768b2c03..bee6017c348 100644 --- a/packages/model/addon/-private/model.js +++ b/packages/model/addon/-private/model.js @@ -119,8 +119,10 @@ function computeOnce(target, key, desc) { @uses DeprecatedEvented */ class Model extends EmberObject { - init(...args) { - super.init(...args); + init(options = {}) { + const createProps = options._createProps; + delete options._createProps; + super.init(options); if (DEBUG) { if (!this._internalModel) { @@ -133,6 +135,7 @@ class Model extends EmberObject { if (CUSTOM_MODEL_CLASS) { this.___recordState = new RecordState(this); } + this.setProperties(createProps); } /** @@ -2076,6 +2079,7 @@ class Model extends EmberObject { // the values initialized during create to `setUnknownProperty` Model.prototype._internalModel = null; Model.prototype.store = null; +Model.prototype._createProps = null; if (HAS_DEBUG_PACKAGE) { /** diff --git a/packages/model/addon/-private/system/promise-many-array.ts b/packages/model/addon/-private/system/promise-many-array.ts index 6c0a6d58d38..69557348dfd 100644 --- a/packages/model/addon/-private/system/promise-many-array.ts +++ b/packages/model/addon/-private/system/promise-many-array.ts @@ -1,3 +1,4 @@ +import ArrayMixin from '@ember/array'; import { assert } from '@ember/debug'; import { dependentKeyCompat } from '@ember/object/compat'; import { tracked } from '@glimmer/tracking'; @@ -16,7 +17,7 @@ import { DEPRECATE_EVENTED_API_USAGE } from '@ember-data/private-build-infra/dep A PromiseManyArray is an array-like proxy that also proxies certain method calls to the underlying ManyArray in addition to being "promisified". - + Right now we proxy: * `reload()` @@ -41,6 +42,14 @@ export default class PromiseManyArray { this._update(promise, content); this.isDestroyed = false; this.isDestroying = false; + + const meta = Ember.meta(this); + meta.hasMixin = (mixin: Object) => { + if (mixin === ArrayMixin) { + return true; + } + return false; + }; } //---- Methods/Properties on ArrayProxy that we will keep as our API 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 index 3283f78c0c5..f2908b16ab9 100644 --- a/packages/record-data/addon/-private/graph/operations/replace-related-records.ts +++ b/packages/record-data/addon/-private/graph/operations/replace-related-records.ts @@ -73,19 +73,15 @@ export default function replaceRelatedRecords(graph: Graph, op: ReplaceRelatedRe 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 newValues = new Set(identifiers); + const identifiersLength = identifiers.length; + const newState = new Array(newValues.size); const newMembership = new Set(); // wipe existing state @@ -103,6 +99,9 @@ function replaceRelatedRecordsLocal(graph: Graph, op: ReplaceRelatedRecordsOpera for (let i = 0; i < iterationLength; i++) { if (i < identifiersLength) { const identifier = identifiers[i]; + if (newMembership.has(identifier)) { + break; // skip processing if we encounter a duplicate identifier in the array + } if (type !== identifier.type) { assertPolymorphicType(relationship.identifier, relationship.definition, identifier, graph.store); graph.registerPolymorphicType(type, identifier.type); @@ -123,7 +122,7 @@ function replaceRelatedRecordsLocal(graph: Graph, op: ReplaceRelatedRecordsOpera changed = true; } - if (!newValues[identifier.lid]) { + if (!newValues.has(identifier)) { changed = true; removeFromInverse(graph, identifier, definition.inverseKey, op.record, isRemote); } @@ -137,7 +136,6 @@ function replaceRelatedRecordsLocal(graph: Graph, op: ReplaceRelatedRecordsOpera 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( @@ -149,14 +147,11 @@ function replaceRelatedRecordsRemote(graph: Graph, op: ReplaceRelatedRecordsOper } 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 newValues = new Set(identifiers); + const identifiersLength = identifiers.length; + const newState = new Array(newValues.size); const newMembership = new Set(); // wipe existing state @@ -174,6 +169,9 @@ function replaceRelatedRecordsRemote(graph: Graph, op: ReplaceRelatedRecordsOper for (let i = 0; i < iterationLength; i++) { if (i < identifiersLength) { const identifier = identifiers[i]; + if (newMembership.has(identifier)) { + break; + } if (type !== identifier.type) { assertPolymorphicType(relationship.identifier, relationship.definition, identifier, graph.store); graph.registerPolymorphicType(type, identifier.type); @@ -194,7 +192,7 @@ function replaceRelatedRecordsRemote(graph: Graph, op: ReplaceRelatedRecordsOper changed = true; } - if (!newValues[identifier.lid]) { + if (!newValues.has(identifier)) { changed = true; removeFromInverse(graph, identifier, definition.inverseKey, op.record, isRemote); } diff --git a/packages/record-data/addon/-private/graph/operations/update-relationship.ts b/packages/record-data/addon/-private/graph/operations/update-relationship.ts index a4c055d2f9e..0a85556ac9b 100644 --- a/packages/record-data/addon/-private/graph/operations/update-relationship.ts +++ b/packages/record-data/addon/-private/graph/operations/update-relationship.ts @@ -152,7 +152,5 @@ export default function updateRelationshipOperation(graph: Graph, op: UpdateRela } else { relationship.state.isStale = false; } - } else { - relationship.state.isStale = false; } } diff --git a/packages/record-data/addon/-private/record-data.ts b/packages/record-data/addon/-private/record-data.ts index 4428c985965..d41599006bc 100644 --- a/packages/record-data/addon/-private/record-data.ts +++ b/packages/record-data/addon/-private/record-data.ts @@ -108,7 +108,9 @@ export default class RecordDataDefault implements RelationshipRecordData { } if (data.id) { - this.id = coerceId(data.id); + if (!this.id) { + this.id = coerceId(data.id); + } } return changedKeys; diff --git a/packages/store/addon/-private/system/core-store.ts b/packages/store/addon/-private/system/core-store.ts index 8aee0836ada..6e975654224 100644 --- a/packages/store/addon/-private/system/core-store.ts +++ b/packages/store/addon/-private/system/core-store.ts @@ -1759,7 +1759,7 @@ abstract class CoreStore extends Service { if (arguments.length === 1 && isMaybeIdentifier(identifier)) { let stableIdentifier = identifierCacheFor(this).peekRecordIdentifier(identifier); if (stableIdentifier) { - return internalModelFactoryFor(this).peek(stableIdentifier)?.getRecord(); + return internalModelFactoryFor(this).peek(stableIdentifier)?.getRecord() || null; } return null; } diff --git a/packages/store/addon/-private/system/ds-model-store.ts b/packages/store/addon/-private/system/ds-model-store.ts index 41ff62a1da9..5a391b89715 100644 --- a/packages/store/addon/-private/system/ds-model-store.ts +++ b/packages/store/addon/-private/system/ds-model-store.ts @@ -37,9 +37,10 @@ class Store extends CoreStore { let createOptions: any = { store: this, _internalModel: internalModel, + // TODO deprecate allowing unknown args setting + _createProps: createRecordArgs, container: null, }; - Object.assign(createOptions, createRecordArgs); // ensure that `getOwner(this)` works inside a model instance setOwner(createOptions, getOwner(this)); diff --git a/packages/store/addon/-private/system/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 4f17e896950..477c438a204 100644 --- a/packages/store/addon/-private/system/model/internal-model.ts +++ b/packages/store/addon/-private/system/model/internal-model.ts @@ -24,6 +24,7 @@ import type { import type { UpgradedMeta } from '@ember-data/record-data/-private/graph/-edge-definition'; import { identifierCacheFor } from '../../identifiers/cache'; +import { DSModel } from '../../ts-interfaces/ds-model'; import type { StableRecordIdentifier } from '../../ts-interfaces/identifier'; import type { RecordData } from '../../ts-interfaces/record-data'; import type { JsonApiResource, JsonApiValidationError } from '../../ts-interfaces/record-data-json-api'; @@ -127,7 +128,7 @@ export default class InternalModel { declare _deferredTriggers: any; declare __recordArrays: any; declare references: any; - declare _recordReference: any; + declare _recordReference: RecordReference; declare _manyArrayCache: ConfidentDict; declare _relationshipPromisesCache: ConfidentDict>; @@ -199,7 +200,7 @@ export default class InternalModel { } } - get recordReference() { + get recordReference(): RecordReference { if (this._recordReference === null) { this._recordReference = new RecordReference(this.store, this.identifier); } @@ -291,7 +292,7 @@ export default class InternalModel { } } - getRecord(properties?) { + getRecord(properties?): Object { if (!this._record && !this._isDematerializing) { let { store } = this; @@ -613,7 +614,7 @@ export default class InternalModel { "' with id " + parentInternalModel.id + ' but some of the associated records were not loaded. Either make sure they are all loaded together with the parent record, or specify that the relationship is async (`belongsTo({ async: true })`)', - toReturn === null || !toReturn.get('isEmpty') + toReturn === null || !(toReturn as DSModel).isEmpty ); return toReturn; } @@ -672,7 +673,7 @@ export default class InternalModel { assert(`hasMany only works with the @ember-data/record-data package`); } - getHasMany(key: string, options) { + getHasMany(key: string, options?) { if (HAS_RECORD_DATA_PACKAGE) { const graphFor = require('@ember-data/record-data/-private').graphFor; const relationship = graphFor(this.store).get(this.identifier, key); @@ -790,11 +791,22 @@ export default class InternalModel { !this._record || this._record.get('isDestroyed') || this._record.get('isDestroying') ); this.isDestroying = true; + if (this._recordReference) { + this._recordReference.destroy(); + } + this._recordReference = null; let cache = this._manyArrayCache; Object.keys(cache).forEach((key) => { cache[key].destroy(); delete cache[key]; }); + if (this.references) { + cache = this.references; + Object.keys(cache).forEach((key) => { + cache[key].destroy(); + delete cache[key]; + }); + } internalModelFactoryFor(this.store).remove(this); this._isDestroyed = true; @@ -803,6 +815,7 @@ export default class InternalModel { setupData(data) { let changedKeys = this._recordData.pushData(data, this.hasRecord); if (this.hasRecord) { + // TODO @runspired should this be going through the notification manager? this._record._notifyProperties(changedKeys); } this.send('pushedData'); @@ -902,11 +915,18 @@ export default class InternalModel { notifyHasManyChange(key: string) { if (this.hasRecord) { + let manyArray = this._manyArrayCache[key]; + let hasPromise = !!this._relationshipPromisesCache[key]; + + if (manyArray && hasPromise) { + // do nothing, we will notify the ManyArray directly + // once the fetch has completed. + return; + } + if (CUSTOM_MODEL_CLASS) { this.store._notificationManager.notify(this.identifier, 'relationships', key); } else { - let manyArray = this._manyArrayCache[key]; - if (manyArray) { manyArray.notify(); @@ -956,10 +976,10 @@ export default class InternalModel { this.store._notificationManager.notify(this.identifier, 'state'); } else { if (!key || key === 'isNew') { - this.getRecord().notifyPropertyChange('isNew'); + (this.getRecord() as DSModel).notifyPropertyChange('isNew'); } if (!key || key === 'isDeleted') { - this.getRecord().notifyPropertyChange('isDeleted'); + (this.getRecord() as DSModel).notifyPropertyChange('isDeleted'); } } } @@ -1263,12 +1283,12 @@ export default class InternalModel { if (this._recordData.getErrors) { return this._recordData.getErrors(this.identifier).length > 0; } else { - let errors = get(this.getRecord(), 'errors'); - return errors.get('length') > 0; + let errors = (this.getRecord() as DSModel).errors; + return errors.length > 0; } } else { - let errors = get(this.getRecord(), 'errors'); - return errors.get('length') > 0; + let errors = (this.getRecord() as DSModel).errors; + return errors.length > 0; } } @@ -1283,7 +1303,7 @@ export default class InternalModel { if (!this._recordData.getErrors) { for (attribute in parsedErrors) { if (hasOwnProperty.call(parsedErrors, attribute)) { - this.getRecord().errors._add(attribute, parsedErrors[attribute]); + (this.getRecord() as DSModel).errors._add(attribute, parsedErrors[attribute]); } } } @@ -1303,7 +1323,7 @@ export default class InternalModel { for (attribute in parsedErrors) { if (hasOwnProperty.call(parsedErrors, attribute)) { - this.getRecord().errors._add(attribute, parsedErrors[attribute]); + (this.getRecord() as DSModel).errors._add(attribute, parsedErrors[attribute]); } } diff --git a/packages/store/addon/-private/system/model/states.js b/packages/store/addon/-private/system/model/states.js index 05c3fac55cd..7cbe6ceab70 100644 --- a/packages/store/addon/-private/system/model/states.js +++ b/packages/store/addon/-private/system/model/states.js @@ -3,7 +3,7 @@ */ import { assert } from '@ember/debug'; -import { REQUEST_SERVICE } from '@ember-data/canary-features'; +import { CUSTOM_MODEL_CLASS, REQUEST_SERVICE } from '@ember-data/canary-features'; /* This file encapsulates the various states that a record can transition through during its lifecycle. @@ -431,6 +431,12 @@ createdState.uncommitted.rollback = function (internalModel) { }; createdState.uncommitted.pushedData = function (internalModel) { + // TODO @runspired consider where to do this once we kill off state machine + if (CUSTOM_MODEL_CLASS) { + internalModel.store._notificationManager.notify(internalModel.identifier, 'identity'); + } else { + internalModel.notifyPropertyChange('id'); + } internalModel.transitionTo('loaded.updated.uncommitted'); internalModel.triggerLater('didLoad'); }; diff --git a/packages/store/addon/-private/system/record-notification-manager.ts b/packages/store/addon/-private/system/record-notification-manager.ts index c4ff1b5b9b4..d5589307726 100644 --- a/packages/store/addon/-private/system/record-notification-manager.ts +++ b/packages/store/addon/-private/system/record-notification-manager.ts @@ -4,7 +4,7 @@ import type CoreStore from './core-store'; type UnsubscribeToken = Object; -const Cache = new WeakMap(); +const Cache = new WeakMap>(); const Tokens = new WeakMap(); export type NotificationType = @@ -29,7 +29,8 @@ export function unsubscribe(token: UnsubscribeToken) { throw new Error('Passed unknown unsubscribe token to unsubscribe'); } Tokens.delete(token); - Cache.delete(identifier); + const map = Cache.get(identifier); + map?.delete(token); } /* Currently only support a single callback per identifier @@ -39,8 +40,13 @@ export default class NotificationManager { subscribe(identifier: RecordIdentifier, callback: NotificationCallback): UnsubscribeToken { let stableIdentifier = identifierCacheFor(this.store).getOrCreateRecordIdentifier(identifier); - Cache.set(stableIdentifier, callback); + let map = Cache.get(stableIdentifier); + if (map === undefined) { + map = new Map(); + Cache.set(stableIdentifier, map); + } let unsubToken = {}; + map.set(unsubToken, callback); Tokens.set(unsubToken, stableIdentifier); return unsubToken; } @@ -49,11 +55,13 @@ export default class NotificationManager { notify(identifier: RecordIdentifier, value: 'errors' | 'meta' | 'identity' | 'unload' | 'state'): boolean; notify(identifier: RecordIdentifier, value: NotificationType, key?: string): boolean { let stableIdentifier = identifierCacheFor(this.store).getOrCreateRecordIdentifier(identifier); - let callback = Cache.get(stableIdentifier); - if (!callback) { + let callbackMap = Cache.get(stableIdentifier); + if (!callbackMap || !callbackMap.size) { return false; } - callback(stableIdentifier, value, key); + callbackMap.forEach((cb) => { + cb(stableIdentifier, value, key); + }); return true; } } diff --git a/packages/store/addon/-private/system/references/belongs-to.js b/packages/store/addon/-private/system/references/belongs-to.ts similarity index 72% rename from packages/store/addon/-private/system/references/belongs-to.js rename to packages/store/addon/-private/system/references/belongs-to.ts index 31f0eefa0db..902bd4c847e 100644 --- a/packages/store/addon/-private/system/references/belongs-to.js +++ b/packages/store/addon/-private/system/references/belongs-to.ts @@ -1,11 +1,20 @@ import { deprecate } from '@ember/debug'; +import { dependentKeyCompat } from '@ember/object/compat'; +import { cached, tracked } from '@glimmer/tracking'; import { resolve } from 'rsvp'; +import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; import { DEPRECATE_BELONGS_TO_REFERENCE_PUSH } from '@ember-data/private-build-infra/deprecations'; +import type { BelongsToRelationship } from '@ember-data/record-data/-private'; import { assertPolymorphicType } from '@ember-data/store/-debug'; +import { SingleResourceDocument } from '../../ts-interfaces/ember-data-json-api'; +import { StableRecordIdentifier } from '../../ts-interfaces/identifier'; +import CoreStore from '../core-store'; +import { NotificationType, unsubscribe } from '../record-notification-manager'; import { internalModelFactoryFor, peekRecordIdentifier, recordIdentifierFor } from '../store/internal-model-factory'; +import RecordReference from './record'; import Reference from './reference'; /** @@ -22,17 +31,81 @@ import Reference from './reference'; @extends Reference */ export default class BelongsToReference extends Reference { - constructor(store, parentIMOrIdentifier, belongsToRelationship, key) { - super(store, parentIMOrIdentifier); + declare key: string; + declare belongsToRelationship: BelongsToRelationship; + declare type: string; + declare parent: RecordReference; + declare parentIdentifier: StableRecordIdentifier; + + // unsubscribe tokens given to us by the notification manager + #token!: Object; + #relatedToken: Object | null = null; + + @tracked _ref = 0; + + constructor( + store: CoreStore, + parentIdentifier: StableRecordIdentifier, + belongsToRelationship: BelongsToRelationship, + key: string + ) { + super(store, parentIdentifier); this.key = key; this.belongsToRelationship = belongsToRelationship; this.type = belongsToRelationship.definition.type; - this.parent = internalModelFactoryFor(store).peek(parentIMOrIdentifier).recordReference; - this.parentIdentifier = parentIMOrIdentifier; + const parent = internalModelFactoryFor(store).peek(parentIdentifier); + this.parent = parent!.recordReference; + this.parentIdentifier = parentIdentifier; + + if (CUSTOM_MODEL_CLASS) { + this.#token = store._notificationManager.subscribe( + parentIdentifier, + (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { + if ((bucket === 'relationships' || bucket === 'property') && notifiedKey === key) { + this._ref++; + } + } + ); + } // TODO inverse } + destroy() { + if (CUSTOM_MODEL_CLASS) { + unsubscribe(this.#token); + if (this.#relatedToken) { + unsubscribe(this.#relatedToken); + } + } + } + + @cached + @dependentKeyCompat + get _relatedIdentifier(): StableRecordIdentifier | null { + this._ref; // consume the tracked prop + if (this.#relatedToken) { + unsubscribe(this.#relatedToken); + } + + let resource = this._resource(); + if (resource && resource.data) { + const identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resource.data); + this.#relatedToken = this.store._notificationManager.subscribe( + identifier, + (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { + if (bucket === 'identity' || ((bucket === 'attributes' || bucket === 'property') && notifiedKey === 'id')) { + this._ref++; + } + } + ); + + return identifier; + } + + return null; + } + /** The `id` of the record that this reference refers to. Together, the `type()` and `id()` methods form a composite key for the identity @@ -73,13 +146,18 @@ export default class BelongsToReference extends Reference { @public @return {String} The id of the record in this belongsTo relationship. */ - id() { - let id = null; + id(): string | null { + if (CUSTOM_MODEL_CLASS) { + return this._relatedIdentifier?.id || null; + } let resource = this._resource(); if (resource && resource.data) { - id = resource.data.id; + const identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resource.data); + + return identifier.id; } - return id; + + return null; } _resource() { @@ -132,10 +210,10 @@ export default class BelongsToReference extends Reference { @param {Object|Promise} objectOrPromise a promise that resolves to a JSONAPI document object describing the new value of this relationship. @return {Promise} A promise that resolves with the new value in this belongs-to relationship. */ - push(objectOrPromise) { + async push(objectOrPromise: Object | SingleResourceDocument): Promise { // TODO deprecate thenable support return resolve(objectOrPromise).then((data) => { - let record; + let record: Object; if (DEPRECATE_BELONGS_TO_REFERENCE_PUSH && peekRecordIdentifier(data)) { deprecate('Pushing a record into a BelongsToReference is deprecated', false, { @@ -147,15 +225,16 @@ export default class BelongsToReference extends Reference { enabled: '3.16', }, }); - record = data; + record = data as Object; } else { - record = this.store.push(data); + record = this.store.push(data as SingleResourceDocument); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-call assertPolymorphicType( this.belongsToRelationship.identifier, this.belongsToRelationship.definition, - record._internalModel.identifier, + recordIdentifierFor(record), this.store ); @@ -223,7 +302,7 @@ export default class BelongsToReference extends Reference { @public @return {Model} the record in this relationship */ - value() { + value(): Object | null { let resource = this._resource(); if (resource && resource.data) { let inverseInternalModel = this.store._internalModelForResource(resource.data); @@ -299,7 +378,7 @@ export default class BelongsToReference extends Reference { */ load(options) { let parentInternalModel = internalModelFactoryFor(this.store).peek(this.parentIdentifier); - return parentInternalModel.getBelongsTo(this.key, options); + return parentInternalModel!.getBelongsTo(this.key, options); } /** @@ -354,7 +433,7 @@ export default class BelongsToReference extends Reference { */ reload(options) { let parentInternalModel = internalModelFactoryFor(this.store).peek(this.parentIdentifier); - return parentInternalModel.reloadBelongsTo(this.key, options).then((internalModel) => { + return parentInternalModel!.reloadBelongsTo(this.key, options).then((internalModel) => { return this.value(); }); } diff --git a/packages/store/addon/-private/system/references/has-many.js b/packages/store/addon/-private/system/references/has-many.ts similarity index 62% rename from packages/store/addon/-private/system/references/has-many.js rename to packages/store/addon/-private/system/references/has-many.ts index 5c702b9fa09..490ecc85d0f 100644 --- a/packages/store/addon/-private/system/references/has-many.js +++ b/packages/store/addon/-private/system/references/has-many.ts @@ -1,10 +1,23 @@ +import { dependentKeyCompat } from '@ember/object/compat'; import { DEBUG } from '@glimmer/env'; +import { cached, tracked } from '@glimmer/tracking'; import { resolve } from 'rsvp'; +import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; +import type { ManyRelationship } from '@ember-data/record-data/-private'; import { assertPolymorphicType } from '@ember-data/store/-debug'; +import { + CollectionResourceDocument, + ExistingResourceObject, + SingleResourceDocument, +} from '../../ts-interfaces/ember-data-json-api'; +import { StableRecordIdentifier } from '../../ts-interfaces/identifier'; +import CoreStore from '../core-store'; +import { NotificationType, unsubscribe } from '../record-notification-manager'; import { internalModelFactoryFor, recordIdentifierFor } from '../store/internal-model-factory'; +import RecordReference from './record'; import Reference, { internalModelForReference } from './reference'; /** @@ -20,17 +33,88 @@ import Reference, { internalModelForReference } from './reference'; @extends Reference */ export default class HasManyReference extends Reference { - constructor(store, parentIMOrIdentifier, hasManyRelationship, key) { - super(store, parentIMOrIdentifier); + declare key: string; + declare hasManyRelationship: ManyRelationship; + declare type: string; + declare parent: RecordReference; + declare parentIdentifier: StableRecordIdentifier; + + // unsubscribe tokens given to us by the notification manager + #token!: Object; + #relatedTokenMap!: Map; + + @tracked _ref = 0; + + constructor( + store: CoreStore, + parentIdentifier: StableRecordIdentifier, + hasManyRelationship: ManyRelationship, + key: string + ) { + super(store, parentIdentifier); this.key = key; this.hasManyRelationship = hasManyRelationship; this.type = hasManyRelationship.definition.type; - this.parent = internalModelFactoryFor(store).peek(parentIMOrIdentifier).recordReference; + this.parent = internalModelFactoryFor(store).peek(parentIdentifier)!.recordReference; + if (CUSTOM_MODEL_CLASS) { + this.#token = store._notificationManager.subscribe( + parentIdentifier, + (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { + if ((bucket === 'relationships' || bucket === 'property') && notifiedKey === key) { + this._ref++; + } + } + ); + this.#relatedTokenMap = new Map(); + } // TODO inverse } + destroy() { + if (CUSTOM_MODEL_CLASS) { + unsubscribe(this.#token); + this.#relatedTokenMap.forEach((token) => { + unsubscribe(token); + }); + this.#relatedTokenMap.clear(); + } + } + + @cached + @dependentKeyCompat + get _relatedIdentifiers(): StableRecordIdentifier[] { + this._ref; // consume the tracked prop + + let resource = this._resource(); + + this.#relatedTokenMap.forEach((token) => { + unsubscribe(token); + }); + this.#relatedTokenMap.clear(); + + if (resource && resource.data) { + return resource.data.map((resourceIdentifier) => { + const identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resourceIdentifier); + const token = this.store._notificationManager.subscribe( + identifier, + (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { + if (bucket === 'identity' || ((bucket === 'attributes' || bucket === 'property') && notifiedKey === 'id')) { + this._ref++; + } + } + ); + + this.#relatedTokenMap.set(identifier, token); + + return identifier; + }); + } + + return []; + } + _resource() { return this.recordData.getHasMany(this.key); } @@ -77,7 +161,7 @@ export default class HasManyReference extends Reference { @public @return {String} The name of the remote type. This should either be `link` or `ids` */ - remoteType() { + remoteType(): 'link' | 'ids' { let value = this._resource(); if (value && value.links && value.links.related) { return 'link'; @@ -121,15 +205,22 @@ export default class HasManyReference extends Reference { @public @return {Array} The ids in this has-many relationship */ - ids() { + ids(): Array { + if (CUSTOM_MODEL_CLASS) { + return this._relatedIdentifiers.map((identifier) => identifier.id); + } + let resource = this._resource(); - let ids = []; - if (resource.data) { - ids = resource.data.map((data) => data.id); + if (resource && resource.data) { + return resource.data.map((resourceIdentifier) => { + const identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resourceIdentifier); + + return identifier.id; + }); } - return ids; + return []; } /** @@ -177,45 +268,50 @@ export default class HasManyReference extends Reference { @param {Array|Promise} objectOrPromise a promise that resolves to a JSONAPI document object describing the new value of this relationship. @return {ManyArray} */ - push(objectOrPromise) { - return resolve(objectOrPromise).then((payload) => { - let array = payload; - - if (typeof payload === 'object' && payload.data) { - array = payload.data; - } + async push( + objectOrPromise: ExistingResourceObject[] | CollectionResourceDocument | { data: SingleResourceDocument[] } + ): Promise { + const payload = await resolve(objectOrPromise); + let array: Array; + + if (!Array.isArray(payload) && typeof payload === 'object' && Array.isArray(payload.data)) { + array = payload.data; + } else { + array = payload as ExistingResourceObject[]; + } - let internalModel = internalModelForReference(this); + const internalModel = internalModelForReference(this)!; + const { store } = this; - let identifiers = array.map((obj) => { - let record = this.store.push(obj); + let identifiers = array.map((obj) => { + let record; + if ('data' in obj) { + // TODO deprecate pushing non-valid JSON:API here + record = store.push(obj); + } else { + record = store.push({ data: obj }); + } - if (DEBUG) { - let relationshipMeta = this.hasManyRelationship.definition; - assertPolymorphicType( - internalModel.identifier, - relationshipMeta, - record._internalModel.identifier, - this.store - ); - } - return recordIdentifierFor(record); - }); + if (DEBUG) { + let relationshipMeta = this.hasManyRelationship.definition; + let identifier = this.hasManyRelationship.identifier; + assertPolymorphicType(identifier, relationshipMeta, recordIdentifierFor(record), store); + } + return recordIdentifierFor(record); + }); - const { graph, identifier } = this.hasManyRelationship; - this.store._backburner.join(() => { - graph.push({ - op: 'replaceRelatedRecords', - record: identifier, - field: this.key, - value: identifiers, - }); + const { graph, identifier } = this.hasManyRelationship; + store._backburner.join(() => { + graph.push({ + op: 'replaceRelatedRecords', + record: identifier, + field: this.key, + value: identifiers, }); - - return internalModel.getHasMany(this.key); - // TODO IGOR it seems wrong that we were returning the many array here - //return this.hasManyRelationship.manyArray; }); + + // TODO IGOR it seems wrong that we were returning the many array here + return internalModel.getHasMany(this.key); } _isLoaded() { @@ -275,7 +371,7 @@ export default class HasManyReference extends Reference { @return {ManyArray} */ value() { - let internalModel = internalModelForReference(this); + let internalModel = internalModelForReference(this)!; if (this._isLoaded()) { return internalModel.getManyArray(this.key); } @@ -348,7 +444,7 @@ export default class HasManyReference extends Reference { this has-many relationship. */ load(options) { - let internalModel = internalModelForReference(this); + let internalModel = internalModelForReference(this)!; return internalModel.getHasMany(this.key, options); } @@ -403,7 +499,7 @@ export default class HasManyReference extends Reference { @return {Promise} a promise that resolves with the ManyArray in this has-many relationship. */ reload(options) { - let internalModel = internalModelForReference(this); + let internalModel = internalModelForReference(this)!; return internalModel.reloadHasMany(this.key, options); } } diff --git a/packages/store/addon/-private/system/references/record.ts b/packages/store/addon/-private/system/references/record.ts index 0a400ad025a..870c9839085 100644 --- a/packages/store/addon/-private/system/references/record.ts +++ b/packages/store/addon/-private/system/references/record.ts @@ -1,8 +1,15 @@ +import { dependentKeyCompat } from '@ember/object/compat'; +import { cached, tracked } from '@glimmer/tracking'; + import RSVP, { resolve } from 'rsvp'; +import { CUSTOM_MODEL_CLASS } from '@ember-data/canary-features'; + import type { SingleResourceDocument } from '../../ts-interfaces/ember-data-json-api'; import type { StableRecordIdentifier } from '../../ts-interfaces/identifier'; import type { RecordInstance } from '../../ts-interfaces/record-instance'; +import type CoreStore from '../core-store'; +import { NotificationType, unsubscribe } from '../record-notification-manager'; import Reference, { internalModelForReference, REFERENCE_CACHE } from './reference'; /** @@ -18,11 +25,39 @@ import Reference, { internalModelForReference, REFERENCE_CACHE } from './referen @extends Reference */ export default class RecordReference extends Reference { + // unsubscribe token given to us by the notification manager + #token!: Object; + + @tracked _ref = 0; + + constructor(public store: CoreStore, identifier: StableRecordIdentifier) { + super(store, identifier); + if (CUSTOM_MODEL_CLASS) { + this.#token = store._notificationManager.subscribe( + identifier, + (_: StableRecordIdentifier, bucket: NotificationType, notifiedKey?: string) => { + if (bucket === 'identity' || ((bucket === 'attributes' || bucket === 'property') && notifiedKey === 'id')) { + this._ref++; + } + } + ); + } + } + + destroy() { + if (CUSTOM_MODEL_CLASS) { + unsubscribe(this.#token); + } + } + public get type(): string { return this.identifier().type; } + @cached + @dependentKeyCompat private get _id(): string | null { + this._ref; // consume the tracked prop let identifier = this.identifier(); if (identifier) { return identifier.id; @@ -50,7 +85,15 @@ export default class RecordReference extends Reference { @return {String} The id of the record. */ id() { - return this._id; + if (CUSTOM_MODEL_CLASS) { + return this._id; + } + let identifier = this.identifier(); + if (identifier) { + return identifier.id; + } + + return null; } /** @@ -92,7 +135,7 @@ export default class RecordReference extends Reference { @public @return {String} 'identity' */ - remoteType(): 'link' | 'id' | 'identity' { + remoteType(): 'identity' { return 'identity'; } @@ -159,7 +202,7 @@ export default class RecordReference extends Reference { @return {Model} the record for this RecordReference */ value(): RecordInstance | null { - if (this._id !== null) { + if (this.id() !== null) { let internalModel = internalModelForReference(this); if (internalModel && internalModel.currentState.isLoaded) { return internalModel.getRecord(); @@ -186,8 +229,9 @@ export default class RecordReference extends Reference { @return {Promise} the record for this RecordReference */ load() { - if (this._id !== null) { - return this.store.findRecord(this.type, this._id); + const id = this.id(); + if (id !== null) { + return this.store.findRecord(this.type, id); } throw new Error(`Unable to fetch record of type ${this.type} without an id`); } @@ -210,8 +254,9 @@ export default class RecordReference extends Reference { @return {Promise} the record for this RecordReference */ reload() { - if (this._id !== null) { - return this.store.findRecord(this.type, this._id, { reload: true }); + const id = this.id(); + if (id !== null) { + return this.store.findRecord(this.type, id, { reload: true }); } throw new Error(`Unable to fetch record of type ${this.type} without an id`); } diff --git a/packages/store/addon/-private/system/references/reference.ts b/packages/store/addon/-private/system/references/reference.ts index 878d64949a1..df3c8cd6e56 100644 --- a/packages/store/addon/-private/system/references/reference.ts +++ b/packages/store/addon/-private/system/references/reference.ts @@ -98,7 +98,7 @@ abstract class Reference { @public @return {String} The name of the remote type. This should either be "link" or "ids" */ - remoteType(): 'link' | 'id' | 'identity' { + remoteType(): 'link' | 'id' | 'ids' | 'identity' { let value = this._resource(); if (isResourceIdentiferWithRelatedLinks(value)) { return 'link'; diff --git a/packages/store/addon/-private/ts-interfaces/ds-model.ts b/packages/store/addon/-private/ts-interfaces/ds-model.ts index 79a3f3ece1b..6a1a83572db 100644 --- a/packages/store/addon/-private/ts-interfaces/ds-model.ts +++ b/packages/store/addon/-private/ts-interfaces/ds-model.ts @@ -17,6 +17,7 @@ export interface DSModel extends RecordInstance, EmberObject { isDeleted: boolean; deleteRecord(): void; unloadRecord(): void; + errors: any; } // Implemented by both ShimModelClass and DSModel diff --git a/packages/store/index.js b/packages/store/index.js index 22677323e1b..a531797f0ca 100644 --- a/packages/store/index.js +++ b/packages/store/index.js @@ -10,6 +10,8 @@ module.exports = Object.assign({}, addonBaseConfig, { shouldRollupPrivate: true, externalDependenciesForPrivateModule() { return [ + 'ember-cached-decorator-polyfill', + '@ember-data/canary-features', '@ember-data/store/-debug', @@ -23,6 +25,7 @@ module.exports = Object.assign({}, addonBaseConfig, { '@ember/object/evented', '@ember/object/internals', '@ember/object/mixin', + '@ember/object/compat', '@ember/object/promise-proxy-mixin', '@ember/object/proxy', '@ember/polyfills', diff --git a/packages/store/package.json b/packages/store/package.json index c2f1bf4606b..375a62fae6b 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -19,6 +19,7 @@ "dependencies": { "@ember-data/canary-features": "4.0.1", "@ember-data/private-build-infra": "4.0.1", + "ember-cached-decorator-polyfill": "^0.1.4", "@ember/string": "^3.0.0", "@glimmer/tracking": "^1.0.4", "ember-auto-import": "^2.2.4", diff --git a/packages/store/types/ember/index.d.ts b/packages/store/types/ember/index.d.ts index 0cac9e5580b..bc7e03a32b9 100644 --- a/packages/store/types/ember/index.d.ts +++ b/packages/store/types/ember/index.d.ts @@ -2,3 +2,4 @@ export function run(callback: Function); export const ENV: { DS_WARN_ON_UNKNOWN_KEYS?: boolean; }; +export function meta(obj: Object): any; diff --git a/tsconfig.json b/tsconfig.json index 585bc9287d5..88598316026 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,8 @@ "packages/store/addon/-private/system/snapshot-record-array.ts", "packages/store/addon/-private/system/schema-definition-service.ts", "packages/store/addon/-private/system/request-cache.ts", + "packages/store/addon/-private/system/references/belongs-to.ts", + "packages/store/addon/-private/system/references/has-many.ts", "packages/store/addon/-private/system/references/reference.ts", "packages/store/addon/-private/system/references/record.ts", "packages/store/addon/-private/system/record-notification-manager.ts",