diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0198adb092f..3625064868f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -318,7 +318,7 @@ jobs: ember-observer, # ember-resource-metadata, factory-guy, - # ilios-frontend, + ilios-frontend, model-fragments, storefront, travis-web, @@ -330,10 +330,14 @@ jobs: continue-on-error: true - partner: travis-web continue-on-error: true + - partner: ilios-frontend + continue-on-error: true - partner: model-fragments continue-on-error: true - partner: ember-observer continue-on-error: true + - partner: ember-m3 + continue-on-error: true steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -345,8 +349,12 @@ jobs: - name: Generate package tarballs run: node ./scripts/packages-for-commit.js - name: Run Tests + id: test-partner timeout-minutes: 16 env: CI: true run: yarn test-external:${{ matrix.partner }} continue-on-error: ${{ matrix['continue-on-error'] == true }} + - name: Check on failures + if: ${{ matrix['continue-on-error'] == true && steps.test-partner.outcome == 'success' }} + run: exit 1 diff --git a/packages/-ember-data/tests/integration/adapter/rest-adapter-test.js b/packages/-ember-data/tests/integration/adapter/rest-adapter-test.js index 4786e8ee845..6694aa242a7 100644 --- a/packages/-ember-data/tests/integration/adapter/rest-adapter-test.js +++ b/packages/-ember-data/tests/integration/adapter/rest-adapter-test.js @@ -827,7 +827,7 @@ module('integration/adapter/rest_adapter - REST Adapter', function (hooks) { await store.findAll('post'); - let comment = store.peekRecord('comment', 1); + let comment = store.peekRecord('comment', '1'); assert.deepEqual(comment.getProperties('id', 'name'), { id: '1', name: 'FIRST' }); }); @@ -1509,7 +1509,7 @@ module('integration/adapter/rest_adapter - REST Adapter', function (hooks) { this.owner.register('model:comment', Comment); adapter.coalesceFindRequests = true; - store.push({ + const post = store.push({ data: { type: 'post', id: '1', @@ -1528,7 +1528,6 @@ module('integration/adapter/rest_adapter - REST Adapter', function (hooks) { }, }); - let post = await store.findRecord('post', 1); ajaxResponse({ comments: [ { id: '1', name: 'FIRST' }, @@ -1541,11 +1540,11 @@ module('integration/adapter/rest_adapter - REST Adapter', function (hooks) { let comments = await post.comments; - let comment1 = store.peekRecord('comment', 1); - let comment2 = store.peekRecord('comment', 2); - let comment3 = store.peekRecord('comment', 3); - let comment4 = store.peekRecord('comment', 4); - let post2 = store.peekRecord('post', 2); + let comment1 = store.peekRecord('comment', '1'); + let comment2 = store.peekRecord('comment', '2'); + let comment3 = store.peekRecord('comment', '3'); + let comment4 = store.peekRecord('comment', '4'); + let post2 = store.peekRecord('post', '2'); assert.deepEqual(comments.toArray(), [comment1, comment2, comment3], 'The correct records are in the array'); @@ -1948,16 +1947,19 @@ module('integration/adapter/rest_adapter - REST Adapter', function (hooks) { }, }); - assert.expectWarning(async () => { - try { - await post.comments; - } catch (e) { - assert.strictEqual( - e.message, - `Expected: '' to be present in the adapter provided payload, but it was not found.` - ); - } - }, /expected to find records with the following ids in the adapter response but they were missing: \[ "2", "3" \]/); + assert.expectWarning( + async () => { + try { + await post.comments; + } catch (e) { + assert.strictEqual( + e.message, + `Expected: '' to be present in the adapter provided payload, but it was not found.` + ); + } + }, + { id: 'ds.store.missing-records-from-adapter' } + ); } ); diff --git a/packages/-ember-data/tests/unit/store/adapter-interop-test.js b/packages/-ember-data/tests/unit/store/adapter-interop-test.js index d1d3a1cbd13..10c0bd1940e 100644 --- a/packages/-ember-data/tests/unit/store/adapter-interop-test.js +++ b/packages/-ember-data/tests/unit/store/adapter-interop-test.js @@ -830,7 +830,7 @@ module('unit/store/adapter-interop - Store working with a Adapter', function (ho }); testInDebug( - 'store._fetchRecord reject records that were not found, even when those requests were coalesced with records that were found', + 'store.findRecord reject records that were not found, even when those requests were coalesced with records that were found', function (assert) { assert.expect(3); @@ -862,7 +862,7 @@ module('unit/store/adapter-interop - Store working with a Adapter', function (ho } ); - testInDebug('store._fetchRecord warns when records are missing', function (assert) { + testInDebug('store.findRecord warns when records are missing', function (assert) { const ApplicationAdapter = Adapter.extend({ findMany(store, type, ids, snapshots) { let records = ids.map((id) => ({ id, type: 'test' })).filter(({ id }) => id === 'david'); @@ -880,20 +880,23 @@ module('unit/store/adapter-interop - Store working with a Adapter', function (ho let wait = []; let igorDidReject = true; - assert.expectWarning(() => { - run(() => { - wait.push(store.findRecord('test', 'david')); - wait.push( - store.findRecord('test', 'igor').catch((e) => { - igorDidReject = true; - assert.strictEqual( - e.message, - `Expected: '' to be present in the adapter provided payload, but it was not found.` - ); - }) - ); - }); - }, /expected to find records with the following ids in the adapter response but they were missing/); + assert.expectWarning( + () => { + run(() => { + wait.push(store.findRecord('test', 'david')); + wait.push( + store.findRecord('test', 'igor').catch((e) => { + igorDidReject = true; + assert.strictEqual( + e.message, + `Expected: '' to be present in the adapter provided payload, but it was not found.` + ); + }) + ); + }); + }, + { id: 'ds.store.missing-records-from-adapter' } + ); return EmberPromise.all(wait).then(() => { assert.ok( diff --git a/packages/record-data/addon/-private/record-data.ts b/packages/record-data/addon/-private/record-data.ts index 36b1577cd34..0408807def2 100644 --- a/packages/record-data/addon/-private/record-data.ts +++ b/packages/record-data/addon/-private/record-data.ts @@ -672,7 +672,7 @@ export default class RecordDataDefault implements RelationshipRecordData { @private */ /* - TODO IGOR DAVID + TODO @deprecate IGOR DAVID There seems to be a potential bug here, where we will return keys that are not in the schema */ diff --git a/packages/serializer/addon/json-api.js b/packages/serializer/addon/json-api.js index 5c225dd6b1d..e96f542033a 100644 --- a/packages/serializer/addon/json-api.js +++ b/packages/serializer/addon/json-api.js @@ -374,7 +374,6 @@ const JSONAPISerializer = JSONSerializer.extend({ @param {String} modelName @return {String} */ - // TODO @deprecated Use payloadTypeFromModelName instead payloadKeyFromModelName(modelName) { return pluralize(modelName); }, diff --git a/packages/store/addon/-private/caches/identifier-cache.ts b/packages/store/addon/-private/caches/identifier-cache.ts index dd205cb64d7..cbd421318e7 100644 --- a/packages/store/addon/-private/caches/identifier-cache.ts +++ b/packages/store/addon/-private/caches/identifier-cache.ts @@ -123,11 +123,11 @@ export class IdentifierCache { lids: Object.create(null) as IdentifierMap, types: Object.create(null) as TypeMap, }; - private _generate: GenerationMethod; - private _update: UpdateMethod; - private _forget: ForgetMethod; - private _reset: ResetMethod; - private _merge: MergeMethod; + declare _generate: GenerationMethod; + declare _update: UpdateMethod; + declare _forget: ForgetMethod; + declare _reset: ResetMethod; + declare _merge: MergeMethod; constructor() { // we cache the user configuredGenerationMethod at init because it must @@ -156,12 +156,9 @@ export class IdentifierCache { * @method _getRecordIdentifier * @private */ - private _getRecordIdentifier(resource: ResourceIdentifierObject, shouldGenerate: true): StableRecordIdentifier; - private _getRecordIdentifier( - resource: ResourceIdentifierObject, - shouldGenerate: false - ): StableRecordIdentifier | undefined; - private _getRecordIdentifier( + _getRecordIdentifier(resource: ResourceIdentifierObject, shouldGenerate: true): StableRecordIdentifier; + _getRecordIdentifier(resource: ResourceIdentifierObject, shouldGenerate: false): StableRecordIdentifier | undefined; + _getRecordIdentifier( resource: ResourceIdentifierObject, shouldGenerate: boolean = false ): StableRecordIdentifier | undefined { diff --git a/packages/store/addon/-private/legacy-model-support/record-reference.ts b/packages/store/addon/-private/legacy-model-support/record-reference.ts index 29671118d41..f620ceca934 100644 --- a/packages/store/addon/-private/legacy-model-support/record-reference.ts +++ b/packages/store/addon/-private/legacy-model-support/record-reference.ts @@ -27,13 +27,15 @@ import type Store from '../store-service'; @extends Reference */ export default class RecordReference { + declare store: Store; // unsubscribe token given to us by the notification manager #token!: Object; #identifier: StableRecordIdentifier; @tracked _ref = 0; - constructor(public store: Store, identifier: StableRecordIdentifier) { + constructor(store: Store, identifier: StableRecordIdentifier) { + this.store = store; this.#identifier = identifier; this.#token = store._notificationManager.subscribe( identifier, diff --git a/packages/store/addon/-private/legacy-model-support/shim-model-class.ts b/packages/store/addon/-private/legacy-model-support/shim-model-class.ts index c70500d10d6..ed36bcd6351 100644 --- a/packages/store/addon/-private/legacy-model-support/shim-model-class.ts +++ b/packages/store/addon/-private/legacy-model-support/shim-model-class.ts @@ -33,8 +33,12 @@ function mapFromHash(hash: Dict): Map { // Mimics the static apis of DSModel export default class ShimModelClass implements ModelSchema { - // TODO Maybe expose the class here? - constructor(private __store: Store, public modelName: string) {} + declare __store: Store; + declare modelName: string; + constructor(store: Store, modelName: string) { + this.__store = store; + this.modelName = modelName; + } get fields(): Map { let attrs = this.__store.getSchemaDefinitionService().attributesDefinitionFor({ type: this.modelName }); diff --git a/packages/store/addon/-private/managers/record-notification-manager.ts b/packages/store/addon/-private/managers/record-notification-manager.ts index fffc1c84b15..0e100f6ee42 100644 --- a/packages/store/addon/-private/managers/record-notification-manager.ts +++ b/packages/store/addon/-private/managers/record-notification-manager.ts @@ -37,7 +37,10 @@ export function unsubscribe(token: UnsubscribeToken) { Currently only support a single callback per identifier */ export default class NotificationManager { - constructor(private store: Store) {} + declare store: Store; + constructor(store: Store) { + this.store = store; + } subscribe(identifier: StableRecordIdentifier, callback: NotificationCallback): UnsubscribeToken { assert(`Expected to receive a stable Identifier to subscribe to`, isStableIdentifier(identifier)); diff --git a/packages/store/addon/-private/network/fetch-manager.ts b/packages/store/addon/-private/network/fetch-manager.ts index db236d8073d..058b9764a5b 100644 --- a/packages/store/addon/-private/network/fetch-manager.ts +++ b/packages/store/addon/-private/network/fetch-manager.ts @@ -15,16 +15,15 @@ import type { StableExistingRecordIdentifier, StableRecordIdentifier, } from '@ember-data/types/q/identifier'; +import { MinimumAdapterInterface } from '@ember-data/types/q/minimum-adapter-interface'; import type { MinimumSerializerInterface } from '@ember-data/types/q/minimum-serializer-interface'; import type { FindOptions } from '@ember-data/types/q/store'; -import type { Dict } from '@ember-data/types/q/utils'; import ShimModelClass from '../legacy-model-support/shim-model-class'; import type Store from '../store-service'; import coerceId from '../utils/coerce-id'; import { _bind, _guard, _objectIsAlive, guardDestroyedStore } from '../utils/common'; import { normalizeResponseHelper } from '../utils/serializer-response'; -import WeakCache from '../utils/weak-cache'; import RequestCache from './request-cache'; import Snapshot from './snapshot'; @@ -75,8 +74,10 @@ export default class FetchManager { declare _pendingSave: PendingSaveItem[]; // fetches pending in the runloop, waiting to be coalesced declare _pendingFetch: Map; + declare _store: Store; - constructor(private _store: Store) { + constructor(store: Store) { + this._store = store; // used to keep track of all the find requests that need to be coalesced this._pendingFetch = new Map(); this._pendingSave = []; @@ -125,59 +126,6 @@ export default class FetchManager { return resolver.promise; } - _flushPendingSave(pending: PendingSaveItem) { - let { snapshot, resolver, identifier, options } = pending; - let adapter = this._store.adapterFor(identifier.type); - let operation = options[SaveOp]; - - let modelName = snapshot.modelName; - let store = this._store; - let modelClass = store.modelFor(modelName); - const record = store._instanceCache.getRecord(identifier); - - assert(`You tried to update a record but you have no adapter (for ${modelName})`, adapter); - assert( - `You tried to update a record but your adapter (for ${modelName}) does not implement '${operation}'`, - typeof adapter[operation] === 'function' - ); - - let promise = resolve().then(() => adapter[operation](store, modelClass, snapshot)); - let serializer: SerializerWithParseErrors | null = store.serializerFor(modelName); - let label = `DS: Extract and notify about ${operation} completion of ${identifier}`; - - assert( - `Your adapter's '${operation}' method must return a value, but it returned 'undefined'`, - promise !== undefined - ); - - promise = _guard(guardDestroyedStore(promise, store, label), _bind(_objectIsAlive, record)).then( - (adapterPayload) => { - if (!_objectIsAlive(record)) { - if (DEPRECATE_RSVP_PROMISE) { - deprecate( - `A Promise while saving ${modelName} did not resolve by the time your model was destroyed. This will error in a future release.`, - false, - { - id: 'ember-data:rsvp-unresolved-async', - until: '5.0', - for: '@ember-data/store', - since: { - available: '4.5', - enabled: '4.5', - }, - } - ); - } - } - - if (adapterPayload) { - return normalizeResponseHelper(serializer, store, modelClass, adapterPayload, snapshot.id, operation); - } - } - ); - resolver.resolve(promise); - } - /** This method is called at the end of the run loop, and flushes any records passed into `scheduleSave` @@ -186,11 +134,12 @@ export default class FetchManager { @internal */ _flushPendingSaves() { + const store = this._store; let pending = this._pendingSave.slice(); this._pendingSave = []; for (let i = 0, j = pending.length; i < j; i++) { let pendingItem = pending[i]; - this._flushPendingSave(pendingItem); + _flushPendingSave(store, pendingItem); } } @@ -288,288 +237,316 @@ export default class FetchManager { return promise; } - _fetchRecord(fetchItem: PendingFetchItem) { - let identifier = fetchItem.identifier; - let modelName = identifier.type; - let adapter = this._store.adapterFor(modelName); - - assert(`You tried to find a record but you have no adapter (for ${modelName})`, adapter); - assert( - `You tried to find a record but your adapter (for ${modelName}) does not implement 'findRecord'`, - typeof adapter.findRecord === 'function' - ); + getPendingFetch(identifier: StableRecordIdentifier, options: FindOptions) { + let pendingFetches = this._pendingFetch.get(identifier.type); - let snapshot = new Snapshot(fetchItem.options, identifier, this._store); - let klass = this._store.modelFor(identifier.type); - let id = identifier.id; - let label = `DS: Handle Adapter#findRecord of '${modelName}' with id: '${id}'`; - - let promise = guardDestroyedStore( - resolve().then(() => { - return adapter.findRecord(this._store, klass, identifier.id, snapshot); - }), - this._store, - label - ).then((adapterPayload) => { - assert( - `You made a 'findRecord' request for a '${modelName}' with id '${id}', but the adapter's response did not have any data`, - !!payloadIsNotBlank(adapterPayload) - ); - let serializer = this._store.serializerFor(modelName); - let payload = normalizeResponseHelper(serializer, this._store, klass, adapterPayload, id, 'findRecord'); - assert( - `Ember Data expected the primary data returned from a 'findRecord' response to be an object but instead it found an array.`, - !Array.isArray(payload.data) - ); - assert( - `The 'findRecord' request for ${modelName}:${id} resolved indicating success but contained no primary data. To indicate a 404 not found you should either reject the promise returned by the adapter's findRecord method or throw a NotFoundError.`, - 'data' in payload && payload.data !== null && typeof payload.data === 'object' + // We already have a pending fetch for this + if (pendingFetches) { + let matchingPendingFetch = pendingFetches.find( + (fetch) => fetch.identifier === identifier && isSameRequest(options, fetch.options) ); + if (matchingPendingFetch) { + return matchingPendingFetch.promise; + } + } + } - warn( - `You requested a record of type '${modelName}' with id '${id}' but the adapter returned a payload with primary data having an id of '${payload.data.id}'. Use 'store.findRecord()' when the requested id is the same as the one returned by the adapter. In other cases use 'store.queryRecord()' instead.`, - coerceId(payload.data.id) === coerceId(id), - { - id: 'ds.store.findRecord.id-mismatch', - } - ); + flushAllPendingFetches() { + if (this.isDestroyed) { + return; + } - return payload; - }); + const store = this._store; + this._pendingFetch.forEach((fetchItem, type) => _flushPendingFetchForType(store, fetchItem, type)); + this._pendingFetch.clear(); + } - fetchItem.resolver.resolve(promise); + destroy() { + this.isDestroyed = true; } +} - // TODO should probably refactor expectedSnapshots to be identifiers - handleFoundRecords( - seeking: { [id: string]: PendingFetchItem }, - coalescedPayload: CollectionResourceDocument, - expectedSnapshots: Snapshot[] - ) { - // resolve found records - let found = Object.create(null); - let payloads = coalescedPayload.data; - let coalescedIncluded = coalescedPayload.included || []; - for (let i = 0, l = payloads.length; i < l; i++) { - let payload = payloads[i]; - let pair = seeking[payload.id]; - found[payload.id] = payload; - let included = coalescedIncluded.concat(payloads); - - // TODO remove original data from included - if (pair) { - let resolver = pair.resolver; - resolver.resolve({ data: payload, included }); - } - } +// this function helps resolve whether we have a pending request that we should use instead +function isSameRequest(options: FindOptions = {}, existingOptions: FindOptions = {}) { + let includedMatches = !options.include || options.include === existingOptions.include; + let adapterOptionsMatches = options.adapterOptions === existingOptions.adapterOptions; - // reject missing records + return includedMatches && adapterOptionsMatches; +} - // TODO NOW clean this up to refer to payloads - let missingSnapshots: Snapshot[] = []; +function _findMany( + store: Store, + adapter: MinimumAdapterInterface, + modelName: string, + snapshots: Snapshot[] +): Promise { + let modelClass = store.modelFor(modelName); // `adapter.findMany` gets the modelClass still + const ids = snapshots.map((s) => s.id!); + assert( + `Cannot fetch a record without an id`, + ids.every((v) => v !== null) + ); + assert(`Expected this adapter to implement findMany for coalescing`, adapter.findMany); + let promise = adapter.findMany(store, modelClass, ids, snapshots); + let label = `DS: Handle Adapter#findMany of '${modelName}'`; + + if (promise === undefined) { + throw new Error('adapter.findMany returned undefined, this was very likely a mistake'); + } - for (let i = 0, l = expectedSnapshots.length; i < l; i++) { - let snapshot = expectedSnapshots[i]; - assertIsString(snapshot.id); + promise = guardDestroyedStore(promise, store, label); - // We know id is a string because you can't fetch - // without one. - if (!found[snapshot.id]) { - missingSnapshots.push(snapshot); - } - } + return promise.then((adapterPayload) => { + assert( + `You made a 'findMany' request for '${modelName}' records with ids '[${ids}]', but the adapter's response did not have any data`, + !!payloadIsNotBlank(adapterPayload) + ); + let serializer = store.serializerFor(modelName); + let payload = normalizeResponseHelper(serializer, store, modelClass, adapterPayload, null, 'findMany'); + return payload as CollectionResourceDocument; + }); +} - if (missingSnapshots.length) { - warn( - 'Ember Data expected to find records with the following ids in the adapter response but they were missing: [ "' + - missingSnapshots.map((r) => r.id).join('", "') + - '" ]', - false, - { - id: 'ds.store.missing-records-from-adapter', - } +function rejectFetchedItems(fetchMap: Map, snapshots: Snapshot[], error?) { + for (let i = 0, l = snapshots.length; i < l; i++) { + let snapshot = snapshots[i]; + let pair = fetchMap.get(snapshot); + + if (pair) { + pair.resolver.reject( + error || + new Error( + `Expected: '<${snapshot.modelName}:${snapshot.id}>' to be present in the adapter provided payload, but it was not found.` + ) ); - this.rejectFetchedItems(seeking, missingSnapshots); } } +} - rejectFetchedItems(seeking: { [id: string]: PendingFetchItem }, snapshots: Snapshot[], error?) { - for (let i = 0, l = snapshots.length; i < l; i++) { - let snapshot = snapshots[i]; - assertIsString(snapshot.id); - // TODO refactor to identifier.lid to avoid this cast to string - // we can do this case because you can only fetch an identifier - // that has an ID - let pair = seeking[snapshot.id]; - - if (pair) { - pair.resolver.reject( - error || - new Error( - `Expected: '<${snapshot.modelName}:${snapshot.id}>' to be present in the adapter provided payload, but it was not found.` - ) - ); - } +function handleFoundRecords( + store: Store, + fetchMap: Map, + snapshots: Snapshot[], + coalescedPayload: CollectionResourceDocument +) { + /* + It is possible that the same ID is included multiple times + via multiple snapshots. This happens when more than one + options hash was supplied, each of which must be uniquely + accounted for. + + However, since we can't map from response to a specific + options object, we resolve all snapshots by id with + the first response we see. + */ + let snapshotsById = new Map(); + for (let i = 0; i < snapshots.length; i++) { + let id = snapshots[i].id!; + let snapshotGroup = snapshotsById.get(id); + if (!snapshotGroup) { + snapshotGroup = []; + snapshotsById.set(id, snapshotGroup); } + snapshotGroup.push(snapshots[i]); } - _findMany( - adapter: any, - store: Store, - modelName: string, - snapshots: Snapshot[], - identifiers: RecordIdentifier[], - optionsMap - ) { - let modelClass = store.modelFor(modelName); // `adapter.findMany` gets the modelClass still - let ids = snapshots.map((s) => s.id); - let promise = adapter.findMany(store, modelClass, ids, snapshots); - let label = `DS: Handle Adapter#findMany of '${modelName}'`; - - if (promise === undefined) { - throw new Error('adapter.findMany returned undefined, this was very likely a mistake'); - } - - promise = guardDestroyedStore(promise, store, label); + const included = Array.isArray(coalescedPayload.included) ? coalescedPayload.included : []; - return promise.then( - (adapterPayload) => { - assert( - `You made a 'findMany' request for '${modelName}' records with ids '[${ids}]', but the adapter's response did not have any data`, - !!payloadIsNotBlank(adapterPayload) - ); - let serializer = store.serializerFor(modelName); - let payload = normalizeResponseHelper(serializer, store, modelClass, adapterPayload, null, 'findMany'); - return payload; - }, - null, - `DS: Extract payload of ${modelName}` - ); - } + // resolve found records + let resources = coalescedPayload.data; + for (let i = 0, l = resources.length; i < l; i++) { + let resource = resources[i]; + let snapshotGroup = snapshotsById.get(resource.id); + snapshotsById.delete(resource.id); - _processCoalescedGroup( - seeking: { [id: string]: PendingFetchItem }, - group: Snapshot[], - adapter: any, - optionsMap, - modelName: string - ) { - //TODO check what happened with identifiers here - let totalInGroup = group.length; - let ids = new Array(totalInGroup); - let groupedSnapshots = new Array(totalInGroup); - - for (let j = 0; j < totalInGroup; j++) { - groupedSnapshots[j] = group[j]; - ids[j] = groupedSnapshots[j].id; - } - - let store = this._store; - if (totalInGroup > 1) { - this._findMany(adapter, store, modelName, group, groupedSnapshots, optionsMap) - .then((payloads) => { - this.handleFoundRecords(seeking, payloads, groupedSnapshots); - }) - .catch((error) => { - this.rejectFetchedItems(seeking, groupedSnapshots, error); - }); - } else if (ids.length === 1) { - let pair = seeking[groupedSnapshots[0].id]; - this._fetchRecord(pair); + if (!snapshotGroup) { + // TODO consider whether this should be a deprecation/assertion + included.push(resource); } else { - assert("You cannot return an empty array from adapter's method groupRecordsForFindMany", false); + snapshotGroup.forEach((snapshot) => { + let pair = fetchMap.get(snapshot)!; + let resolver = pair.resolver; + resolver.resolve({ data: resource }); + }); } } - _flushPendingFetchForType(pendingFetchItems: PendingFetchItem[], modelName: string) { - let adapter = this._store.adapterFor(modelName); - let shouldCoalesce = !!adapter.findMany && adapter.coalesceFindRequests; - let totalItems = pendingFetchItems.length; - let identifiers = new Array(totalItems); - let seeking: { [id: string]: PendingFetchItem } = Object.create(null); + if (included.length > 0) { + store._push({ data: null, included }); + } - let optionsMap = new WeakCache(DEBUG ? 'fetch-options' : ''); + if (snapshotsById.size === 0) { + return; + } - for (let i = 0; i < totalItems; i++) { - let pendingItem = pendingFetchItems[i]; - let identifier = pendingItem.identifier; - identifiers[i] = identifier; - optionsMap.set(identifier, pendingItem.options); - seeking[identifier.id] = pendingItem; + // reject missing records + let rejected: Snapshot[] = []; + snapshotsById.forEach((snapshots) => { + rejected.push(...snapshots); + }); + warn( + 'Ember Data expected to find records with the following ids in the adapter response from findMany but they were missing: [ "' + + [...snapshotsById.values()].map((r) => r[0].id).join('", "') + + '" ]', + { + id: 'ds.store.missing-records-from-adapter', } + ); - if (shouldCoalesce) { - // TODO: Improve records => snapshots => records => snapshots - // - // We want to provide records to all store methods and snapshots to all - // adapter methods. To make sure we're doing that we're providing an array - // of snapshots to adapter.groupRecordsForFindMany(), which in turn will - // return grouped snapshots instead of grouped records. - // - // But since the _findMany() finder is a store method we need to get the - // records from the grouped snapshots even though the _findMany() finder - // will once again convert the records to snapshots for adapter.findMany() - let snapshots = new Array(totalItems); - for (let i = 0; i < totalItems; i++) { - // we know options is in the map due to having just set it above - // but TS doesn't know so we cast it - let options = optionsMap.get(identifiers[i]) as Dict; - snapshots[i] = new Snapshot(options, identifiers[i], this._store); - } + rejectFetchedItems(fetchMap, rejected); +} - let groups: Snapshot[][]; - if (adapter.groupRecordsForFindMany) { - groups = adapter.groupRecordsForFindMany(this._store, snapshots); - } else { - groups = [snapshots]; - } +function _fetchRecord(store: Store, fetchItem: PendingFetchItem) { + let identifier = fetchItem.identifier; + let modelName = identifier.type; + let adapter = store.adapterFor(modelName); + + assert(`You tried to find a record but you have no adapter (for ${modelName})`, adapter); + assert( + `You tried to find a record but your adapter (for ${modelName}) does not implement 'findRecord'`, + typeof adapter.findRecord === 'function' + ); + + let snapshot = new Snapshot(fetchItem.options, identifier, store); + let klass = store.modelFor(identifier.type); + let id = identifier.id; + let label = `DS: Handle Adapter#findRecord of '${modelName}' with id: '${id}'`; + + let promise = guardDestroyedStore( + resolve().then(() => { + return adapter.findRecord(store, klass, identifier.id, snapshot); + }), + store, + label + ).then((adapterPayload) => { + assert( + `You made a 'findRecord' request for a '${modelName}' with id '${id}', but the adapter's response did not have any data`, + !!payloadIsNotBlank(adapterPayload) + ); + let serializer = store.serializerFor(modelName); + let payload = normalizeResponseHelper(serializer, store, klass, adapterPayload, id, 'findRecord'); + assert( + `Ember Data expected the primary data returned from a 'findRecord' response to be an object but instead it found an array.`, + !Array.isArray(payload.data) + ); + assert( + `The 'findRecord' request for ${modelName}:${id} resolved indicating success but contained no primary data. To indicate a 404 not found you should either reject the promise returned by the adapter's findRecord method or throw a NotFoundError.`, + 'data' in payload && payload.data !== null && typeof payload.data === 'object' + ); - for (let i = 0, l = groups.length; i < l; i++) { - this._processCoalescedGroup(seeking, groups[i], adapter, optionsMap, modelName); + warn( + `You requested a record of type '${modelName}' with id '${id}' but the adapter returned a payload with primary data having an id of '${payload.data.id}'. Use 'store.findRecord()' when the requested id is the same as the one returned by the adapter. In other cases use 'store.queryRecord()' instead.`, + coerceId(payload.data.id) === coerceId(id), + { + id: 'ds.store.findRecord.id-mismatch', } - } else { - for (let i = 0; i < totalItems; i++) { - this._fetchRecord(pendingFetchItems[i]); - } - } - } + ); - getPendingFetch(identifier: StableRecordIdentifier, options: FindOptions) { - let pendingFetches = this._pendingFetch.get(identifier.type); + return payload; + }); - // We already have a pending fetch for this - if (pendingFetches) { - let matchingPendingFetch = pendingFetches.find((fetch) => fetch.identifier === identifier); - if (matchingPendingFetch && isSameRequest(options, matchingPendingFetch.options)) { - return matchingPendingFetch.promise; - } - } + fetchItem.resolver.resolve(promise); +} + +function _processCoalescedGroup( + store: Store, + fetchMap: Map, + group: Snapshot[], + adapter: MinimumAdapterInterface, + modelName: string +) { + if (group.length > 1) { + _findMany(store, adapter, modelName, group) + .then((payloads: CollectionResourceDocument) => { + handleFoundRecords(store, fetchMap, group, payloads); + }) + .catch((error) => { + rejectFetchedItems(fetchMap, group, error); + }); + } else if (group.length === 1) { + _fetchRecord(store, fetchMap.get(group[0])!); + } else { + assert("You cannot return an empty array from adapter's method groupRecordsForFindMany", false); } +} - flushAllPendingFetches() { - if (this.isDestroyed) { - return; +function _flushPendingFetchForType(store: Store, pendingFetchItems: PendingFetchItem[], modelName: string) { + let adapter = store.adapterFor(modelName); + let shouldCoalesce = !!adapter.findMany && adapter.coalesceFindRequests; + let totalItems = pendingFetchItems.length; + + if (shouldCoalesce) { + let snapshots = new Array(totalItems); + let fetchMap = new Map(); + for (let i = 0; i < totalItems; i++) { + let fetchItem = pendingFetchItems[i]; + snapshots[i] = new Snapshot(fetchItem.options, fetchItem.identifier, store); + fetchMap.set(snapshots[i], fetchItem); } - this._pendingFetch.forEach(this._flushPendingFetchForType, this); - this._pendingFetch.clear(); - } + let groups: Snapshot[][]; + if (adapter.groupRecordsForFindMany) { + groups = adapter.groupRecordsForFindMany(store, snapshots); + } else { + groups = [snapshots]; + } - destroy() { - this.isDestroyed = true; + for (let i = 0, l = groups.length; i < l; i++) { + _processCoalescedGroup(store, fetchMap, groups[i], adapter, modelName); + } + } else { + for (let i = 0; i < totalItems; i++) { + _fetchRecord(store, pendingFetchItems[i]); + } } } -function assertIsString(id: string | null): asserts id is string { - if (DEBUG) { - if (typeof id !== 'string') { - throw new Error(`Cannot fetch record without an id`); +function _flushPendingSave(store: Store, pending: PendingSaveItem) { + const { snapshot, resolver, identifier, options } = pending; + const adapter = store.adapterFor(identifier.type); + const operation = options[SaveOp]; + + let modelName = snapshot.modelName; + let modelClass = store.modelFor(modelName); + const record = store._instanceCache.getRecord(identifier); + + assert(`You tried to update a record but you have no adapter (for ${modelName})`, adapter); + assert( + `You tried to update a record but your adapter (for ${modelName}) does not implement '${operation}'`, + typeof adapter[operation] === 'function' + ); + + let promise = resolve().then(() => adapter[operation](store, modelClass, snapshot)); + let serializer: SerializerWithParseErrors | null = store.serializerFor(modelName); + let label = `DS: Extract and notify about ${operation} completion of ${identifier}`; + + assert( + `Your adapter's '${operation}' method must return a value, but it returned 'undefined'`, + promise !== undefined + ); + + promise = _guard(guardDestroyedStore(promise, store, label), _bind(_objectIsAlive, record)).then((adapterPayload) => { + if (!_objectIsAlive(record)) { + if (DEPRECATE_RSVP_PROMISE) { + deprecate( + `A Promise while saving ${modelName} did not resolve by the time your model was destroyed. This will error in a future release.`, + false, + { + id: 'ember-data:rsvp-unresolved-async', + until: '5.0', + for: '@ember-data/store', + since: { + available: '4.5', + enabled: '4.5', + }, + } + ); + } } - } -} -// this function helps resolve whether we have a pending request that we should use instead -// TODO @runspired @needsTest removing this did not cause any test failures -function isSameRequest(options: FindOptions = {}, reqOptions: FindOptions = {}) { - return options.include === reqOptions.include; + if (adapterPayload) { + return normalizeResponseHelper(serializer, store, modelClass, adapterPayload, snapshot.id, operation); + } + }); + resolver.resolve(promise); } diff --git a/packages/store/addon/-private/network/snapshot.ts b/packages/store/addon/-private/network/snapshot.ts index 4f9133f2b44..09b67209969 100644 --- a/packages/store/addon/-private/network/snapshot.ts +++ b/packages/store/addon/-private/network/snapshot.ts @@ -35,11 +35,11 @@ function schemaIsDSModel(schema: ModelSchema | DSModelSchema): schema is DSModel @public */ export default class Snapshot implements Snapshot { - private __attributes: Dict | null = null; - private _belongsToRelationships: Dict = Object.create(null); - private _belongsToIds: Dict = Object.create(null); - private _hasManyRelationships: Dict = Object.create(null); - private _hasManyIds: Dict = Object.create(null); + declare __attributes: Dict | null; + declare _belongsToRelationships: Dict; + declare _belongsToIds: Dict; + declare _hasManyRelationships: Dict; + declare _hasManyIds: Dict; declare _changedAttributes: ChangedAttributesHash; declare identifier: StableRecordIdentifier; @@ -47,6 +47,7 @@ export default class Snapshot implements Snapshot { declare id: string | null; declare include?: unknown; declare adapterOptions?: Dict; + declare _store: Store; /** * @method constructor @@ -56,8 +57,16 @@ export default class Snapshot implements Snapshot { * @param identifier * @param _store */ - constructor(options: FindOptions, identifier: StableRecordIdentifier, private _store: Store) { - const hasRecord = !!_store._instanceCache.peek({ identifier, bucket: 'record' }); + constructor(options: FindOptions, identifier: StableRecordIdentifier, store: Store) { + this._store = store; + + this.__attributes = null; + this._belongsToRelationships = Object.create(null); + this._belongsToIds = Object.create(null); + this._hasManyRelationships = Object.create(null); + this._hasManyIds = Object.create(null); + + const hasRecord = !!store._instanceCache.peek({ identifier, bucket: 'record' }); this.modelName = identifier.type; /** diff --git a/packages/unpublished-adapter-encapsulation-test-app/tests/integration/coalescing-test.js b/packages/unpublished-adapter-encapsulation-test-app/tests/integration/coalescing-test.js index 059e86e74b0..50b4363aebe 100644 --- a/packages/unpublished-adapter-encapsulation-test-app/tests/integration/coalescing-test.js +++ b/packages/unpublished-adapter-encapsulation-test-app/tests/integration/coalescing-test.js @@ -7,6 +7,7 @@ import { all, resolve } from 'rsvp'; import { setupTest } from 'ember-qunit'; import Model, { attr } from '@ember-data/model'; +import { recordIdentifierFor } from '@ember-data/store'; import deepCopy from '@ember-data/unpublished-test-infra/test-support/deep-copy'; class MinimalSerializer extends EmberObject { @@ -182,6 +183,133 @@ module('integration/coalescing - Coalescing Tests', function (hooks) { assert.deepEqual(serializedRecords, expectedResults, 'each findRecord returns expected result'); }); + test('coalescing works with multiple includes options specified', async function (assert) { + let findRecordCalled = 0; + let findManyCalled = 0; + let groupRecordsForFindManyCalled = 0; + + let expectedResults = { + data: [ + { + id: '1', + type: 'person', + attributes: { + firstName: 'Gaurav', + lastName: 'Munjal', + }, + }, + { + id: '2', + type: 'person', + attributes: { + firstName: 'Chris', + lastName: 'Thoburn', + }, + }, + ], + }; + + let { owner } = this; + let store = owner.lookup('service:store'); + + class TestFindRecordAdapter extends EmberObject { + coalesceFindRequests = true; + + findRecord() { + findRecordCalled++; + } + + findMany(passedStore, type, ids, snapshots) { + findManyCalled++; + + assert.strictEqual(passedStore, store, 'instance of store is passed to findMany'); + assert.strictEqual(type, Person, 'model is passed to findMany'); + + let expectedIds = ['1', '1', '1', '1', '1', '1', '2', '2']; + let expectedIncludes = [undefined, 'users', 'users', 'users', undefined, 'users.foo', 'users.foo', 'users']; + let expectedOptions = [ + undefined, + undefined, + { opt: '1' }, + { opt: '2' }, + { opt: '2' }, + undefined, + undefined, + undefined, + ]; + let includes = snapshots.map((snapshot) => snapshot.include); + let options = snapshots.map((snapshot) => snapshot.adapterOptions); + assert.deepEqual(ids, expectedIds, 'ids are passed to findMany'); + assert.deepEqual(includes, expectedIncludes, 'includes are what was expected'); + assert.deepEqual(options, expectedOptions, 'options are what was expected'); + + snapshots.forEach((snapshot, index) => { + assert.strictEqual(snapshot.modelName, 'person', 'snapshot is passed to findMany with correct modelName'); + assert.strictEqual(snapshot.id, expectedIds[index], 'snapshot is passed to findMany with correct id'); + }); + + return resolve(expectedResults); + } + + groupRecordsForFindMany(store, snapshots) { + groupRecordsForFindManyCalled++; + return [snapshots]; + } + } + + owner.register('adapter:application', TestFindRecordAdapter); + + let person1 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '1' }); + let person2 = store.identifierCache.getOrCreateRecordIdentifier({ type: 'person', id: '2' }); + let promises = [ + store.findRecord('person', '1'), + store.findRecord('person', '1', { include: '' }), + store.findRecord('person', '1', { include: 'users' }), + store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '1' } }), + store.findRecord('person', '1', { include: 'users', adapterOptions: { opt: '2' } }), + store.findRecord('person', '1', { include: 'users' }), + store.findRecord('person', '1', { adapterOptions: { opt: '2' } }), + store.findRecord('person', '1', { include: 'users.foo' }), + store.findRecord('person', '2', { include: 'users.foo' }), + store.findRecord('person', '2', { include: 'users' }), + store.findRecord('person', '2', { include: 'users' }), + store.findRecord('person', '2', { include: '' }), + store.findRecord('person', '2'), + store.findRecord('person', '2', { include: 'users' }), + store.findRecord('person', '2', { include: 'users.foo' }), + ]; + let records = await all(promises); + let foundIdentifiers = records.map((record) => recordIdentifierFor(record)); + let expectedIdentifiers = [ + person1, + person1, + person1, + person1, + person1, + person1, + person1, + person1, + person2, + person2, + person2, + person2, + person2, + person2, + person2, + ]; + expectedResults = expectedResults.data.map((result) => ({ data: result })); + + assert.strictEqual(findRecordCalled, 0, 'findRecord is not called'); + assert.strictEqual(findManyCalled, 1, 'findMany is called once'); + assert.strictEqual(groupRecordsForFindManyCalled, 1, 'groupRecordsForFindMany is called once'); + assert.deepEqual(foundIdentifiers, expectedIdentifiers, 'each findRecord returns expected result'); + + const person1record = store.peekRecord('person', '1'); + const person2record = store.peekRecord('person', '2'); + assert.strictEqual(person1record.firstName, 'Gaurav', 'person 1 loaded'); + assert.strictEqual(person2record.firstName, 'Chris', 'person 2 loaded'); + }); + test('coalesceFindRequests is true and findMany is defined but groupRecordsForFindMany is undefined', async function (assert) { let findRecordCalled = 0; let findManyCalled = 0; diff --git a/packages/unpublished-serializer-encapsulation-test-app/tests/integration/relationships-test.js b/packages/unpublished-serializer-encapsulation-test-app/tests/integration/relationships-test.js index 69f1a238858..6a784dd4ac6 100644 --- a/packages/unpublished-serializer-encapsulation-test-app/tests/integration/relationships-test.js +++ b/packages/unpublished-serializer-encapsulation-test-app/tests/integration/relationships-test.js @@ -25,231 +25,234 @@ class Comment extends Post { post; } -module('integration/relationships - running requests for async relatonships with minimum serializer', function (hooks) { - setupTest(hooks); - - hooks.beforeEach(function () { - this.owner.register('service:store', Store); - this.owner.register('model:post', Post); - this.owner.register('model:comment', Comment); - }); - - test('accessing an async hasMany relationship without links results in serializer.normalizeResponse being called with the requestType findMany', async function (assert) { - let normalizeResponseCalled = 0; - - class TestMinimumSerializer extends EmberObject { - normalizeResponse(store, schema, rawPayload, id, requestType) { - normalizeResponseCalled++; - assert.strictEqual(requestType, 'findMany', 'expected method name is correct'); - assert.deepEqual(rawPayload, { data: [] }); - return { - data: [ - { - id: '1', - type: 'comment', - attributes: { - message: 'Message 1', - }, - relationships: { - post: { - data: { - id: '1', - type: 'post', +module( + 'integration/relationships - running requests for async relationships with minimum serializer', + function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('service:store', Store); + this.owner.register('model:post', Post); + this.owner.register('model:comment', Comment); + }); + + test('accessing an async hasMany relationship without links results in serializer.normalizeResponse being called with the requestType findMany', async function (assert) { + let normalizeResponseCalled = 0; + + class TestMinimumSerializer extends EmberObject { + normalizeResponse(store, schema, rawPayload, id, requestType) { + normalizeResponseCalled++; + assert.strictEqual(requestType, 'findMany', 'expected method name is correct'); + assert.deepEqual(rawPayload, { data: [] }); + return { + data: [ + { + id: '1', + type: 'comment', + attributes: { + message: 'Message 1', + }, + relationships: { + post: { + data: { + id: '1', + type: 'post', + }, }, }, }, - }, - { - id: '2', - type: 'comment', - attributes: { - message: 'Message 2', - }, - relationships: { - post: { - data: { - id: '1', - type: 'post', + { + id: '2', + type: 'comment', + attributes: { + message: 'Message 2', + }, + relationships: { + post: { + data: { + id: '1', + type: 'post', + }, }, }, }, - }, - ], - }; + ], + }; + } } - } - this.owner.register('serializer:application', TestMinimumSerializer); + this.owner.register('serializer:application', TestMinimumSerializer); - class TestAdapter extends JSONAPIAdapter { - coalesceFindRequests = true; + class TestAdapter extends JSONAPIAdapter { + coalesceFindRequests = true; - ajax(url, type) { - return resolve({ data: [] }); + ajax(url, type) { + return resolve({ data: [] }); + } } - } - this.owner.register('adapter:application', TestAdapter); + this.owner.register('adapter:application', TestAdapter); - const store = this.owner.lookup('service:store'); + const store = this.owner.lookup('service:store'); - let post = store.push({ - data: { - id: '1', - type: 'post', - attributes: { - title: 'Post 1', + let post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + title: 'Post 1', + }, + relationships: { + comments: { + data: [ + { + id: '1', + type: 'comment', + }, + { + id: '2', + type: 'comment', + }, + ], + }, + }, }, - relationships: { - comments: { + }); + let comments = await post.comments; + + assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); + assert.deepEqual(comments.mapBy('message'), ['Message 1', 'Message 2'], 'response is expected response'); + }); + + test('accessing an async hasMany relationship with links results in serializer.normalizeResponse being called with the requestType findHasMany', async function (assert) { + let normalizeResponseCalled = 0; + + class TestMinimumSerializer extends EmberObject { + normalizeResponse(store, schema, rawPayload, id, requestType) { + normalizeResponseCalled++; + assert.strictEqual(requestType, 'findHasMany', 'expected method name is correct'); + assert.deepEqual(rawPayload, { data: [] }); + return { data: [ { id: '1', type: 'comment', + attributes: { + message: 'Message 1', + }, }, { id: '2', type: 'comment', + attributes: { + message: 'Message 2', + }, }, ], - }, - }, - }, - }); - let comments = await post.comments; - - assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); - assert.deepEqual(comments.mapBy('message'), ['Message 1', 'Message 2'], 'response is expected response'); - }); - - test('accessing an async hasMany relationship with links results in serializer.normalizeResponse being called with the requestType findHasMany', async function (assert) { - let normalizeResponseCalled = 0; - - class TestMinimumSerializer extends EmberObject { - normalizeResponse(store, schema, rawPayload, id, requestType) { - normalizeResponseCalled++; - assert.strictEqual(requestType, 'findHasMany', 'expected method name is correct'); - assert.deepEqual(rawPayload, { data: [] }); - return { - data: [ - { - id: '1', - type: 'comment', - attributes: { - message: 'Message 1', - }, - }, - { - id: '2', - type: 'comment', - attributes: { - message: 'Message 2', - }, - }, - ], - }; + }; + } } - } - this.owner.register('serializer:application', TestMinimumSerializer); + this.owner.register('serializer:application', TestMinimumSerializer); - class TestAdapter extends JSONAPIAdapter { - coalesceFindRequests = true; + class TestAdapter extends JSONAPIAdapter { + coalesceFindRequests = true; - ajax(url, type) { - return resolve({ data: [] }); + ajax(url, type) { + return resolve({ data: [] }); + } } - } - this.owner.register('adapter:application', TestAdapter); + this.owner.register('adapter:application', TestAdapter); - const store = this.owner.lookup('service:store'); + const store = this.owner.lookup('service:store'); - let post = store.push({ - data: { - id: '1', - type: 'post', - attributes: { - title: 'Post 1', - }, - relationships: { - comments: { - links: { - related: '/posts/1/comments', + let post = store.push({ + data: { + id: '1', + type: 'post', + attributes: { + title: 'Post 1', + }, + relationships: { + comments: { + links: { + related: '/posts/1/comments', + }, }, }, }, - }, + }); + let comments = await post.comments; + + assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); + assert.deepEqual(comments.mapBy('message'), ['Message 1', 'Message 2'], 'response is expected response'); }); - let comments = await post.comments; - - assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); - assert.deepEqual(comments.mapBy('message'), ['Message 1', 'Message 2'], 'response is expected response'); - }); - - test('accessing an async belongsTo relationship with links results in serializer.normalizeResponse being called with the requestType findBelongsTo', async function (assert) { - let normalizeResponseCalled = 0; - - class TestMinimumSerializer extends EmberObject { - normalizeResponse(store, schema, rawPayload, id, requestType) { - normalizeResponseCalled++; - assert.strictEqual(requestType, 'findBelongsTo', 'expected method name is correct'); - assert.deepEqual(rawPayload, { - data: { - id: '1', - type: 'post', - attributes: { - title: 'John', + + test('accessing an async belongsTo relationship with links results in serializer.normalizeResponse being called with the requestType findBelongsTo', async function (assert) { + let normalizeResponseCalled = 0; + + class TestMinimumSerializer extends EmberObject { + normalizeResponse(store, schema, rawPayload, id, requestType) { + normalizeResponseCalled++; + assert.strictEqual(requestType, 'findBelongsTo', 'expected method name is correct'); + assert.deepEqual(rawPayload, { + data: { + id: '1', + type: 'post', + attributes: { + title: 'John', + }, }, - }, - }); - return { - data: { - id: '1', - type: 'post', - attributes: { - title: 'Chris', + }); + return { + data: { + id: '1', + type: 'post', + attributes: { + title: 'Chris', + }, }, - }, - }; + }; + } } - } - this.owner.register('serializer:application', TestMinimumSerializer); - - class TestAdapter extends JSONAPIAdapter { - coalesceFindRequests = true; - - ajax(url, type) { - return resolve({ - data: { - id: '1', - type: 'post', - attributes: { - title: 'John', + this.owner.register('serializer:application', TestMinimumSerializer); + + class TestAdapter extends JSONAPIAdapter { + coalesceFindRequests = true; + + ajax(url, type) { + return resolve({ + data: { + id: '1', + type: 'post', + attributes: { + title: 'John', + }, }, - }, - }); + }); + } } - } - this.owner.register('adapter:application', TestAdapter); + this.owner.register('adapter:application', TestAdapter); - const store = this.owner.lookup('service:store'); + const store = this.owner.lookup('service:store'); - let comment = store.push({ - data: { - id: '1', - type: 'comment', - attributes: { - message: 'Message 1', - }, - relationships: { - post: { - links: { - related: '/comments/1/post', + let comment = store.push({ + data: { + id: '1', + type: 'comment', + attributes: { + message: 'Message 1', + }, + relationships: { + post: { + links: { + related: '/comments/1/post', + }, }, }, }, - }, - }); - let post = await comment.post; + }); + let post = await comment.post; - assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); - assert.deepEqual(post.title, 'Chris', 'response is expected response'); - }); -}); + assert.strictEqual(normalizeResponseCalled, 1, 'normalizeResponse is called once'); + assert.deepEqual(post.title, 'Chris', 'response is expected response'); + }); + } +);