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/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 28c7fbc83b9..d5dd34824c3 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.strictEqual(get(records, 'length'), 2); - assert.strictEqual(records.objectAt(0).get('name'), 'Vito'); - assert.strictEqual(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/store/addon/-private/system/core-store.ts b/packages/store/addon/-private/system/core-store.ts index 8132676c6c1..9d94997739b 100644 --- a/packages/store/addon/-private/system/core-store.ts +++ b/packages/store/addon/-private/system/core-store.ts @@ -1728,7 +1728,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/model/internal-model.ts b/packages/store/addon/-private/system/model/internal-model.ts index 4f17e896950..6bd3b0c3da7 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; @@ -956,10 +968,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 +1275,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 +1295,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 +1315,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/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 1a1c3f15739..1b93c8cfcab 100644 --- a/packages/store/addon/-private/system/references/reference.ts +++ b/packages/store/addon/-private/system/references/reference.ts @@ -94,7 +94,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 d3024c766ee..dde4f4b9410 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -19,6 +19,7 @@ "dependencies": { "@ember-data/canary-features": "4.2.0-alpha.0", "@ember-data/private-build-infra": "4.2.0-alpha.0", + "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/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", diff --git a/yarn.lock b/yarn.lock index 107b2813e63..04fb30559c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -241,11 +241,6 @@ resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9" integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ== -"@babel/helper-plugin-utils@^7.16.5": - version "7.16.5" - resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.5.tgz#afe37a45f39fce44a3d50a7958129ea5b1a5c074" - integrity sha512-59KHWHXxVA9K4HNF4sbHCf+eJeFe0Te/ZFGqBT4OjXhrwvA04sGfaEGsVTdsjoszq0YTP49RC9UKe5g8uN2RwQ== - "@babel/helper-remap-async-to-generator@^7.13.0": version "7.13.0" resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz#376a760d9f7b4b2077a9dd05aa9c3927cadb2209" @@ -785,11 +780,11 @@ "@babel/helper-plugin-utils" "^7.12.13" "@babel/plugin-transform-object-assign@^7.8.3": - version "7.16.5" - resolved "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.16.5.tgz#8d35b2fd1a4a545aed1f8289680d6d38e57d9f6e" - integrity sha512-KVuJ7sWf6bcXawKVH6ZDQFYcOulObt1IOvl/gvNrkNXzmFf1IdgKOy4thmVomReleXqffMbptmXXMl3zPI7zHw== + version "7.16.0" + resolved "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.16.0.tgz#750c726397f1f6402fb1ceffe9d8ff3595c8a0df" + integrity sha512-TftKY6Hxo5Uf/EIoC3BKQyLvlH46tbtK4xub90vzi9+yS8z1+O/52YHyywCZvYeLPOvv//1j3BPokLuHTWPcbg== dependencies: - "@babel/helper-plugin-utils" "^7.16.5" + "@babel/helper-plugin-utils" "^7.14.5" "@babel/plugin-transform-object-super@^7.12.13": version "7.12.13"