diff --git a/@types/ember-data-qunit-asserts/index.d.ts b/@types/ember-data-qunit-asserts/index.d.ts index 62e6b97ff32..8408db43c58 100644 --- a/@types/ember-data-qunit-asserts/index.d.ts +++ b/@types/ember-data-qunit-asserts/index.d.ts @@ -19,7 +19,7 @@ declare global { } interface Assert { - expectDeprecation(options: { id: string; count: number; until?: string }): void; + expectDeprecation(options: DeprecationConfig, label?: string): void; expectDeprecation(callback: () => unknown, options: DeprecationConfig | string | RegExp): Promise; expectNoDeprecation(callback: () => unknown): Promise; expectWarning(callback: () => unknown, options: WarningConfig | string | RegExp): Promise; diff --git a/ember-data-types/cache/cache.ts b/ember-data-types/cache/cache.ts index 1a184149a1e..0e013b4f429 100644 --- a/ember-data-types/cache/cache.ts +++ b/ember-data-types/cache/cache.ts @@ -115,7 +115,7 @@ export interface Cache { * An implementation might want to do this because * de-referencing records which read from their own * blob is generally safer because the record does - * not require retainining connections to the Store + * not require retaining connections to the Store * and Cache to present data on a per-field basis. * * This generally takes the place of `getAttr` as diff --git a/packages/graph/src/-private/-diff.ts b/packages/graph/src/-private/-diff.ts index 7c118bb542c..634521831e7 100644 --- a/packages/graph/src/-private/-diff.ts +++ b/packages/graph/src/-private/-diff.ts @@ -176,7 +176,7 @@ export function diffCollection( if (DEBUG) { deprecate( - `Expected all entries in the relationship ${relationship.definition.type}:${relationship.definition.key} to be unique, see log for a list of duplicate entry indeces`, + `Expected all entries in the relationship ${relationship.definition.type}:${relationship.definition.key} to be unique, see log for a list of duplicate entry indices`, false, { id: 'ember-data:deprecate-non-unique-relationship-entries', diff --git a/packages/json-api/src/-private/cache.ts b/packages/json-api/src/-private/cache.ts index b03442389f5..18b2f7c7fb8 100644 --- a/packages/json-api/src/-private/cache.ts +++ b/packages/json-api/src/-private/cache.ts @@ -388,7 +388,7 @@ export default class JSONAPICache implements Cache { * An implementation might want to do this because * de-referencing records which read from their own * blob is generally safer because the record does - * not require retainining connections to the Store + * not require retaining connections to the Store * and Cache to present data on a per-field basis. * * This generally takes the place of `getAttr` as diff --git a/packages/model/src/-private/many-array.ts b/packages/model/src/-private/many-array.ts index ee46938ce64..f46d345b528 100644 --- a/packages/model/src/-private/many-array.ts +++ b/packages/model/src/-private/many-array.ts @@ -1,11 +1,13 @@ /** @module @ember-data/store */ -import { assert } from '@ember/debug'; +import { assert, deprecate } from '@ember/debug'; +import { DEPRECATE_MANY_ARRAY_DUPLICATES } from '@ember-data/deprecations'; import type Store from '@ember-data/store'; import { IDENTIFIER_ARRAY_TAG, + isStableIdentifier, MUTATE, notifyArray, RecordArray, @@ -14,6 +16,8 @@ import { } from '@ember-data/store/-private'; import { IdentifierArrayCreateOptions } from '@ember-data/store/-private/record-arrays/identifier-array'; import type { CreateRecordProperties } from '@ember-data/store/-private/store-service'; +import type { Tag } from '@ember-data/tracking/-private'; +import { addToTransaction } from '@ember-data/tracking/-private'; import type { Cache } from '@ember-data/types/q/cache'; import type { ModelSchema } from '@ember-data/types/q/ds-model'; import type { Links, PaginationLinks } from '@ember-data/types/q/ember-data-json-api'; @@ -163,111 +167,210 @@ export default class RelatedCollection extends RecordArray { this.key = options.key; } - [MUTATE](prop: string, args: unknown[], result?: unknown) { + [MUTATE]( + target: StableRecordIdentifier[], + receiver: typeof Proxy, + prop: string, + args: unknown[], + _TAG: Tag + ): unknown { switch (prop) { case 'length 0': { - this._manager.mutate({ - op: 'replaceRelatedRecords', - record: this.identifier, - field: this.key, - value: [], - }); - break; + Reflect.set(target, 'length', 0); + mutateReplaceRelatedRecords(this, [], _TAG); + return true; } case 'replace cell': { const [index, prior, value] = args as [number, StableRecordIdentifier, StableRecordIdentifier]; - this._manager.mutate({ - op: 'replaceRelatedRecord', - record: this.identifier, - field: this.key, - value, - prior, - index, - }); - break; + target[index] = value; + mutateReplaceRelatedRecord(this, { value, prior, index }, _TAG); + return true; } - case 'push': - this._manager.mutate({ - op: 'addToRelatedRecords', - record: this.identifier, - field: this.key, - value: extractIdentifiersFromRecords(args), - }); - break; - case 'pop': - if (result) { - this._manager.mutate({ - op: 'removeFromRelatedRecords', - record: this.identifier, - field: this.key, - value: recordIdentifierFor(result as RecordInstance), + case 'push': { + const newValues = extractIdentifiersFromRecords(args); + + assertNoDuplicates( + this, + target, + (currentState) => currentState.push(...newValues), + `Cannot push duplicates to a hasMany's state.` + ); + + if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + // dedupe + const seen = new Set(target); + const unique = new Set(); + + args.forEach((item) => { + const identifier = recordIdentifierFor(item); + if (!seen.has(identifier)) { + seen.add(identifier); + unique.add(item); + } }); + + const newArgs = Array.from(unique); + const result = Reflect.apply(target[prop], receiver, newArgs) as RecordInstance[]; + + if (newArgs.length) { + mutateAddToRelatedRecords(this, { value: extractIdentifiersFromRecords(newArgs) }, _TAG); + } + return result; + } + + // else, no dedupe, error on duplicates + const result = Reflect.apply(target[prop], receiver, args) as RecordInstance[]; + if (newValues.length) { + mutateAddToRelatedRecords(this, { value: newValues }, _TAG); } - break; - - case 'unshift': - this._manager.mutate({ - op: 'addToRelatedRecords', - record: this.identifier, - field: this.key, - value: extractIdentifiersFromRecords(args), - index: 0, - }); - break; - - case 'shift': + return result; + } + + case 'pop': { + const result: unknown = Reflect.apply(target[prop], receiver, args); if (result) { - this._manager.mutate({ - op: 'removeFromRelatedRecords', - record: this.identifier, - field: this.key, - value: recordIdentifierFor(result as RecordInstance), - index: 0, + mutateRemoveFromRelatedRecords(this, { value: recordIdentifierFor(result as RecordInstance) }, _TAG); + } + return result; + } + + case 'unshift': { + const newValues = extractIdentifiersFromRecords(args); + + assertNoDuplicates( + this, + target, + (currentState) => currentState.unshift(...newValues), + `Cannot unshift duplicates to a hasMany's state.` + ); + + if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + // dedupe + const seen = new Set(target); + const unique = new Set(); + + args.forEach((item) => { + const identifier = recordIdentifierFor(item); + if (!seen.has(identifier)) { + seen.add(identifier); + unique.add(item); + } }); + + const newArgs = Array.from(unique); + const result: unknown = Reflect.apply(target[prop], receiver, newArgs); + + if (newArgs.length) { + mutateAddToRelatedRecords(this, { value: extractIdentifiersFromRecords(newArgs), index: 0 }, _TAG); + } + return result; } - break; - case 'sort': - this._manager.mutate({ - op: 'sortRelatedRecords', - record: this.identifier, - field: this.key, - value: (result as RecordInstance[]).map(recordIdentifierFor), - }); - break; + // else, no dedupe, error on duplicates + const result = Reflect.apply(target[prop], receiver, args) as RecordInstance[]; + if (newValues.length) { + mutateAddToRelatedRecords(this, { value: newValues, index: 0 }, _TAG); + } + return result; + } + + case 'shift': { + const result: unknown = Reflect.apply(target[prop], receiver, args); + + if (result) { + mutateRemoveFromRelatedRecords( + this, + { value: recordIdentifierFor(result as RecordInstance), index: 0 }, + _TAG + ); + } + return result; + } + + case 'sort': { + const result: unknown = Reflect.apply(target[prop], receiver, args); + mutateSortRelatedRecords(this, (result as RecordInstance[]).map(recordIdentifierFor), _TAG); + return result; + } case 'splice': { - const [start, removeCount, ...adds] = args as [number, number, RecordInstance]; + const [start, deleteCount, ...adds] = args as [number, number, ...RecordInstance[]]; + // detect a full replace - if (removeCount > 0 && adds.length === this[SOURCE].length) { - this._manager.mutate({ - op: 'replaceRelatedRecords', - record: this.identifier, - field: this.key, - value: extractIdentifiersFromRecords(adds), - }); - return; - } - if (removeCount > 0) { - this._manager.mutate({ - op: 'removeFromRelatedRecords', - record: this.identifier, - field: this.key, - value: (result as RecordInstance[]).map(recordIdentifierFor), - index: start, - }); + if (start === 0 && deleteCount === this[SOURCE].length) { + const newValues = extractIdentifiersFromRecords(adds); + + assertNoDuplicates( + this, + target, + (currentState) => currentState.splice(start, deleteCount, ...newValues), + `Cannot replace a hasMany's state with a new state that contains duplicates.` + ); + + if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + // dedupe + const current = new Set(adds); + const unique = Array.from(current); + const newArgs = ([start, deleteCount] as unknown[]).concat(unique); + + const result = Reflect.apply(target[prop], receiver, newArgs) as RecordInstance[]; + + mutateReplaceRelatedRecords(this, extractIdentifiersFromRecords(unique), _TAG); + return result; + } + + // else, no dedupe, error on duplicates + const result = Reflect.apply(target[prop], receiver, args) as RecordInstance[]; + mutateReplaceRelatedRecords(this, newValues, _TAG); + return result; } - if (adds?.length) { - this._manager.mutate({ - op: 'addToRelatedRecords', - record: this.identifier, - field: this.key, - value: extractIdentifiersFromRecords(adds), - index: start, + + const newValues = extractIdentifiersFromRecords(adds); + assertNoDuplicates( + this, + target, + (currentState) => currentState.splice(start, deleteCount, ...newValues), + `Cannot splice a hasMany's state with a new state that contains duplicates.` + ); + + if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + // dedupe + const currentState = target.slice(); + currentState.splice(start, deleteCount); + + const seen = new Set(currentState); + const unique: RecordInstance[] = []; + adds.forEach((item) => { + const identifier = recordIdentifierFor(item); + if (!seen.has(identifier)) { + seen.add(identifier); + unique.push(item); + } }); + + const newArgs = [start, deleteCount, ...unique]; + const result = Reflect.apply(target[prop], receiver, newArgs) as RecordInstance[]; + + if (deleteCount > 0) { + mutateRemoveFromRelatedRecords(this, { value: result.map(recordIdentifierFor), index: start }, _TAG); + } + + if (unique.length > 0) { + mutateAddToRelatedRecords(this, { value: extractIdentifiersFromRecords(unique), index: start }, _TAG); + } + + return result; } - break; + // else, no dedupe, error on duplicates + const result = Reflect.apply(target[prop], receiver, args) as RecordInstance[]; + if (deleteCount > 0) { + mutateRemoveFromRelatedRecords(this, { value: result.map(recordIdentifierFor), index: start }, _TAG); + } + if (newValues.length > 0) { + mutateAddToRelatedRecords(this, { value: newValues, index: start }, _TAG); + } + return result; } default: assert(`unable to convert ${prop} into a transaction that updates the cache state for this record array`); @@ -380,3 +483,137 @@ function extractIdentifierFromRecord(recordOrPromiseRecord: PromiseProxyRecord | assertRecordPassedToHasMany(recordOrPromiseRecord); return recordIdentifierFor(recordOrPromiseRecord); } + +function assertNoDuplicates( + collection: RelatedCollection, + target: StableRecordIdentifier[], + callback: (currentState: StableRecordIdentifier[]) => void, + reason: string +) { + const state = target.slice(); + callback(state); + + if (state.length !== new Set(state).size) { + const duplicates = state.filter((currentValue, currentIndex) => state.indexOf(currentValue) !== currentIndex); + + if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + deprecate( + `${reason} This behavior is deprecated. Found duplicates for the following records within the new state provided to \`<${ + collection.identifier.type + }:${collection.identifier.id || collection.identifier.lid}>.${collection.key}\`\n\t- ${Array.from( + new Set(duplicates) + ) + .map((r) => (isStableIdentifier(r) ? r.lid : recordIdentifierFor(r).lid)) + .sort((a, b) => a.localeCompare(b)) + .join('\n\t- ')}`, + false, + { + id: 'ember-data:deprecate-many-array-duplicates', + for: 'ember-data', + until: '6.0', + since: { + enabled: '5.3', + available: '5.3', + }, + } + ); + } else { + throw new Error( + `${reason} Found duplicates for the following records within the new state provided to \`<${ + collection.identifier.type + }:${collection.identifier.id || collection.identifier.lid}>.${collection.key}\`\n\t- ${Array.from( + new Set(duplicates) + ) + .map((r) => (isStableIdentifier(r) ? r.lid : recordIdentifierFor(r).lid)) + .sort((a, b) => a.localeCompare(b)) + .join('\n\t- ')}` + ); + } + } +} + +function mutateAddToRelatedRecords( + collection: RelatedCollection, + operationInfo: { value: StableRecordIdentifier | StableRecordIdentifier[]; index?: number }, + _TAG: Tag +) { + mutate( + collection, + { + op: 'addToRelatedRecords', + record: collection.identifier, + field: collection.key, + ...operationInfo, + }, + _TAG + ); +} + +function mutateRemoveFromRelatedRecords( + collection: RelatedCollection, + operationInfo: { value: StableRecordIdentifier | StableRecordIdentifier[]; index?: number }, + _TAG: Tag +) { + mutate( + collection, + { + op: 'removeFromRelatedRecords', + record: collection.identifier, + field: collection.key, + ...operationInfo, + }, + _TAG + ); +} + +function mutateReplaceRelatedRecord( + collection: RelatedCollection, + operationInfo: { + value: StableRecordIdentifier; + prior: StableRecordIdentifier; + index: number; + }, + _TAG: Tag +) { + mutate( + collection, + { + op: 'replaceRelatedRecord', + record: collection.identifier, + field: collection.key, + ...operationInfo, + }, + _TAG + ); +} + +function mutateReplaceRelatedRecords(collection: RelatedCollection, value: StableRecordIdentifier[], _TAG: Tag) { + mutate( + collection, + { + op: 'replaceRelatedRecords', + record: collection.identifier, + field: collection.key, + value, + }, + _TAG + ); +} + +function mutateSortRelatedRecords(collection: RelatedCollection, value: StableRecordIdentifier[], _TAG: Tag) { + mutate( + collection, + { + op: 'sortRelatedRecords', + record: collection.identifier, + field: collection.key, + value, + }, + _TAG + ); +} + +function mutate(collection: RelatedCollection, mutation: Parameters[0], _TAG: Tag) { + collection._manager.mutate(mutation); + addToTransaction(_TAG); +} diff --git a/packages/private-build-infra/virtual-packages/deprecations.d.ts b/packages/private-build-infra/virtual-packages/deprecations.d.ts index 5cc95073662..064b4cef983 100644 --- a/packages/private-build-infra/virtual-packages/deprecations.d.ts +++ b/packages/private-build-infra/virtual-packages/deprecations.d.ts @@ -6,3 +6,4 @@ export const DEPRECATE_NON_STRICT_ID: boolean; export const DEPRECATE_LEGACY_IMPORTS: boolean; export const DEPRECATE_NON_UNIQUE_PAYLOADS: boolean; export const DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE: boolean; +export const DEPRECATE_MANY_ARRAY_DUPLICATES: boolean; diff --git a/packages/private-build-infra/virtual-packages/deprecations.js b/packages/private-build-infra/virtual-packages/deprecations.js index b1ce39c4208..5f2256cea3d 100644 --- a/packages/private-build-infra/virtual-packages/deprecations.js +++ b/packages/private-build-infra/virtual-packages/deprecations.js @@ -369,3 +369,20 @@ export const DEPRECATE_NON_UNIQUE_PAYLOADS = '5.3'; * @public */ export const DEPRECATE_RELATIONSHIP_REMOTE_UPDATE_CLEARING_LOCAL_STATE = '5.3'; + +/** + * **id: ember-data:deprecate-many-array-duplicates** + * + * When the flag is `true` (default), adding duplicate records to a `ManyArray` + * is deprecated in non-production environments. In production environments, + * duplicate records added to a `ManyArray` will be deduped and no error will + * be thrown. + * + * When the flag is `false`, an error will be thrown when duplicates are added. + * + * @property DEPRECATE_MANY_ARRAY_DUPLICATES + * @since 5.3 + * @until 6.0 + * @public + */ +export const DEPRECATE_MANY_ARRAY_DUPLICATES = '5.3'; diff --git a/packages/store/src/-private/managers/cache-capabilities-manager.ts b/packages/store/src/-private/managers/cache-capabilities-manager.ts index 3a1e1bcd051..99a39fdf460 100644 --- a/packages/store/src/-private/managers/cache-capabilities-manager.ts +++ b/packages/store/src/-private/managers/cache-capabilities-manager.ts @@ -47,6 +47,8 @@ export class CacheCapabilitiesManager implements StoreWrapper { if (this._store._cbs) { this._store._schedule('notify', () => this._flushNotifications()); } else { + // TODO @runspired determine if relationship mutations should schedule + // into join/run vs immediate flush this._flushNotifications(); } } diff --git a/packages/store/src/-private/managers/cache-manager.ts b/packages/store/src/-private/managers/cache-manager.ts index 6c744735393..7315cfe9702 100644 --- a/packages/store/src/-private/managers/cache-manager.ts +++ b/packages/store/src/-private/managers/cache-manager.ts @@ -55,7 +55,7 @@ export class CacheManager implements Cache { * semantics, `put` has `replace` semantics similar to * the `http` method `PUT` * - * the individually cacheabl + * the individually cacheable * e resource data it may contain * should upsert, but the document data surrounding it should * fully replace any existing information @@ -119,7 +119,7 @@ export class CacheManager implements Cache { * An implementation might want to do this because * de-referencing records which read from their own * blob is generally safer because the record does - * not require retainining connections to the Store + * not require retaining connections to the Store * and Cache to present data on a per-field basis. * * This generally takes the place of `getAttr` as @@ -282,7 +282,7 @@ export class CacheManager implements Cache { // ================ /** - * [LIFECYLCE] Signal to the cache that a new record has been instantiated on the client + * [LIFECYCLE] Signal to the cache that a new record has been instantiated on the client * * It returns properties from options that should be set on the record during the create * process. This return value behavior is deprecated. diff --git a/packages/store/src/-private/record-arrays/identifier-array.ts b/packages/store/src/-private/record-arrays/identifier-array.ts index 891f4c8dc1b..ba9d97fbdc0 100644 --- a/packages/store/src/-private/record-arrays/identifier-array.ts +++ b/packages/store/src/-private/record-arrays/identifier-array.ts @@ -17,6 +17,7 @@ import { Links, PaginationLinks } from '@ember-data/types/q/ember-data-json-api' import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { RecordInstance } from '@ember-data/types/q/record-instance'; +import { isStableIdentifier } from '../caches/identifier-cache'; import { recordIdentifierFor } from '../caches/instance-cache'; import type RecordArrayManager from '../managers/record-array-manager'; import type Store from '../store-service'; @@ -125,9 +126,9 @@ interface PrivateState { links: Links | PaginationLinks | null; meta: Record | null; } -type ForEachCB = (record: RecordInstance, index: number, context: IdentifierArray) => void; +type ForEachCB = (record: RecordInstance, index: number, context: typeof Proxy) => void; function safeForEach( - instance: IdentifierArray, + instance: typeof Proxy, arr: StableRecordIdentifier[], store: Store, callback: ForEachCB, @@ -166,7 +167,13 @@ function safeForEach( @public */ interface IdentifierArray extends Omit, '[]'> { - [MUTATE]?(prop: string, args: unknown[], result?: unknown): void; + [MUTATE]?( + target: StableRecordIdentifier[], + receiver: typeof Proxy, + prop: string, + args: unknown[], + _TAG: Tag + ): unknown; } class IdentifierArray { declare DEPRECATED_CLASS_NAME: string; @@ -237,7 +244,7 @@ class IdentifierArray { constructor(options: IdentifierArrayCreateOptions) { // eslint-disable-next-line @typescript-eslint/no-this-alias - let self = this; + const self = this; this.modelName = options.type; this.store = options.store; this._manager = options.manager; @@ -258,7 +265,7 @@ class IdentifierArray { // and forward them as one const proxy = new Proxy(this[SOURCE], { - get(target: StableRecordIdentifier[], prop: KeyType, receiver: IdentifierArray): unknown { + get(target: StableRecordIdentifier[], prop: KeyType, receiver: typeof Proxy): unknown { let index = convertToInt(prop); if (_TAG.shouldReset && (index !== null || SYNC_PROPS.has(prop) || isArrayGetter(prop))) { options.manager._syncArray(receiver as unknown as IdentifierArray); @@ -296,7 +303,7 @@ class IdentifierArray { // array functions must run through Reflect to work properly // binding via other means will not work. transaction = true; - let result = Reflect.apply(target[prop] as ProxiedMethod, receiver, arguments) as unknown; + const result = Reflect.apply(target[prop] as ProxiedMethod, receiver, arguments) as unknown; transaction = false; return result; }; @@ -322,9 +329,7 @@ class IdentifierArray { const args: unknown[] = Array.prototype.slice.call(arguments); assert(`Cannot start a new array transaction while a previous transaction is underway`, !transaction); transaction = true; - let result: unknown = Reflect.apply(target[prop] as ProxiedMethod, receiver, args); - self[MUTATE]!(prop as string, args, result); - addToTransaction(_TAG); + const result = self[MUTATE]!(target, receiver, prop as string, args, _TAG); // TODO handle cache updates transaction = false; return result; @@ -364,13 +369,16 @@ class IdentifierArray { return target[prop]; }, - set(target: StableRecordIdentifier[], prop: KeyType, value: unknown /*, receiver */): boolean { + set( + target: StableRecordIdentifier[], + prop: KeyType, + value: unknown, + receiver: typeof Proxy + ): boolean { if (prop === 'length') { if (!transaction && value === 0) { transaction = true; - addToTransaction(_TAG); - Reflect.set(target, prop, value); - self[MUTATE]!('length 0', []); + self[MUTATE]!(target, receiver, 'length 0', [], _TAG); transaction = false; return true; } else if (transaction) { @@ -389,8 +397,20 @@ class IdentifierArray { } let index = convertToInt(prop); + // we do not allow "holey" arrays and so if the index is + // greater than length then we will disallow setting it. + // however, there is a special case for "unshift" with more than + // one item being inserted since current items will be moved to the + // new indices first. + // we "loosely" detect this by just checking whether we are in + // a transaction. if (index === null || index > target.length) { - if (prop in self) { + if (index !== null && transaction) { + const identifier = recordIdentifierFor(value); + assert(`Cannot set index ${index} past the end of the array.`, isStableIdentifier(identifier)); + target[index] = identifier; + return true; + } else if (prop in self) { self[prop] = value; return true; } @@ -404,10 +424,27 @@ class IdentifierArray { let original: StableRecordIdentifier | undefined = target[index]; let newIdentifier = extractIdentifierFromRecord(value); - (target as unknown as Record)[index] = newIdentifier; + assert(`Expected a record`, isStableIdentifier(newIdentifier)); + // We generate "transactions" whenever a setter method on the array + // is called and might bulk update multiple array cells. Fundamentally, + // all array operations decompose into individual cell replacements. + // e.g. a push is really a "replace cell at next index with new value" + // or a splice is "shift all values left/right by X and set out of new + // bounds cells to undefined" + // + // so, if we are in a transaction, then this is not a user generated change + // but one generated by a setter method. In this case we want to only apply + // the change to the target array and not call the MUTATE method. + // If there is no transaction though, then this means the user themselves has + // directly changed the value of a specific index and we need to thus generate + // a mutation for that change. + // e.g. "arr.push(newVal)" is handled by a "addToRelatedRecords" mutation within + // a transaction. + // while "arr[arr.length] = newVal;" is handled by this replace cell code path. if (!transaction) { - self[MUTATE]!('replace cell', [index, original, newIdentifier]); - addToTransaction(_TAG); + self[MUTATE]!(target, receiver, 'replace cell', [index, original, newIdentifier], _TAG); + } else { + target[index] = newIdentifier; } return true; diff --git a/packages/tracking/src/-private.ts b/packages/tracking/src/-private.ts index a1bedd43ad1..5af114b381b 100644 --- a/packages/tracking/src/-private.ts +++ b/packages/tracking/src/-private.ts @@ -15,7 +15,7 @@ import { DEBUG } from '@ember-data/env'; * @main @ember-data/tracking */ type OpaqueFn = (...args: unknown[]) => unknown; -type Tag = { ref: null; t: boolean }; +export type Tag = { ref: null; t: boolean }; type Transaction = { cbs: Set; props: Set; diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 4898b6cc795..496908f7aa2 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -56,6 +56,7 @@ module.exports = { '(private) @ember-data/debug InspectorDataAdapter#watchTypeIfUnseen', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_COMPUTED_CHAINS', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_LEGACY_IMPORTS', + '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_MANY_ARRAY_DUPLICATES', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_NON_STRICT_ID', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_NON_STRICT_TYPES', '(public) @ember-data/deprecations CurrentDeprecations#DEPRECATE_NON_UNIQUE_PAYLOADS', diff --git a/tests/graph/tests/integration/graph/edge-removal/abstract-edge-removal-test.ts b/tests/graph/tests/integration/graph/edge-removal/abstract-edge-removal-test.ts index a25b79c0412..c5237d64867 100644 --- a/tests/graph/tests/integration/graph/edge-removal/abstract-edge-removal-test.ts +++ b/tests/graph/tests/integration/graph/edge-removal/abstract-edge-removal-test.ts @@ -116,7 +116,7 @@ module('Integration | Graph | Edge Removal', function (hooks) { * However: for a newly created record any form of rollback, unload or persisted delete * will result in it being destroyed and cleared */ - await testFinalState( + testFinalState( this, testState, config, @@ -178,7 +178,7 @@ module('Integration | Graph | Edge Removal', function (hooks) { // we clear new records, or sync non-implicit relationships (client side delete semantics) let cleared = config.useCreate || (!config.async && !config.inverseNull); - await testFinalState(this, testState, config, { removed, cleared, implicitCleared: true }, assert); + testFinalState(this, testState, config, { removed, cleared, implicitCleared: true }, assert); }); } @@ -224,7 +224,7 @@ module('Integration | Graph | Edge Removal', function (hooks) { if (config.relType === 'hasMany' && !config.async && config.dirtyLocal) { cleared = false; } - await testFinalState(this, testState, config, { removed: true, cleared, implicitCleared: true }, assert); + testFinalState(this, testState, config, { removed: true, cleared, implicitCleared: true }, assert); }); } @@ -254,7 +254,7 @@ module('Integration | Graph | Edge Removal', function (hooks) { await settled(); - await testFinalState(this, testState, config, { removed: true, cleared: true }, assert); + testFinalState(this, testState, config, { removed: true, cleared: true }, assert); }); } diff --git a/tests/graph/tests/integration/graph/edge-removal/helpers.ts b/tests/graph/tests/integration/graph/edge-removal/helpers.ts index 5a260eeee2d..9b0787f66dd 100644 --- a/tests/graph/tests/integration/graph/edge-removal/helpers.ts +++ b/tests/graph/tests/integration/graph/edge-removal/helpers.ts @@ -1,8 +1,6 @@ -import { settled } from '@ember/test-helpers'; - -import type { ImplicitEdge } from '@ember-data/graph/-private/edges/implicit'; import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; import { recordIdentifierFor } from '@ember-data/store'; +import type { CollectionResourceDocument, SingleResourceDocument } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { Context, UserRecord } from './setup'; @@ -73,7 +71,23 @@ interface TestState { johnInverseKey: string; } -export async function setInitialState(context: Context, config: TestConfig, assert): Promise { +type UserRef = { type: 'user'; id: string }; +type BestFriendRel = { + bestFriends: { + data: T; + }; +}; + +function makeRel(id: string | null, isMany: false): BestFriendRel; +function makeRel(id: string | null, isMany: true): BestFriendRel; +function makeRel(id: string | null, isMany: boolean): BestFriendRel { + const ref = { type: 'user', id: id as string } as const; + const data = isMany ? (id === null ? [] : [ref]) : id === null ? null : ref; + + return { bestFriends: { data } }; +} + +export async function setInitialState(context: Context, config: TestConfig, assert: Assert): Promise { const { owner, store, graph } = context; const { identifierCache } = store; const isMany = config.relType === 'hasMany'; @@ -85,33 +99,26 @@ export async function setInitialState(context: Context, config: TestConfig, asse }; class User extends Model { - @attr name; - @relFn('user', relConfig) bestFriends; + @attr declare name: string; + @relFn('user', relConfig) declare bestFriends: unknown; } owner.register('model:user', User); - function makeRel(id: string | null): any { - let ref = { type: 'user', id }; - const data = isMany ? (id === null ? [] : [ref]) : id === null ? null : ref; - - return { bestFriends: { data } }; - } - - let chris, john, johnIdentifier; + let chris: UserRecord, john: UserRecord, johnIdentifier: StableRecordIdentifier; if (!config.useCreate) { - const data = { + const data: CollectionResourceDocument = { data: [ { type: 'user', id: '1', attributes: { name: 'Chris' }, - relationships: makeRel(config.dirtyLocal ? null : '2'), + relationships: makeRel(config.dirtyLocal ? null : '2', isMany as true), }, { type: 'user', id: '2', attributes: { name: 'John' }, - relationships: makeRel(config.dirtyLocal ? null : '1'), + relationships: makeRel(config.dirtyLocal ? null : '1', isMany as true), }, ], }; @@ -125,24 +132,29 @@ export async function setInitialState(context: Context, config: TestConfig, asse id: '1', attributes: { name: 'Chris' }, }, - }); - john = store.createRecord('user', { name: 'John', bestFriends: isMany ? [chris] : chris }); + } as SingleResourceDocument); + john = store.createRecord('user', { name: 'John', bestFriends: isMany ? [chris] : chris }) as UserRecord; johnIdentifier = recordIdentifierFor(john); } if (config.dirtyLocal) { if (isMany) { - let friends = await john.bestFriends; + let friends: UserRecord[] = await (john.bestFriends as unknown as Promise); friends.push(chris); - friends = await chris.bestFriends; - friends.push(john); + if (config.inverseNull) { + friends = await (chris.bestFriends as unknown as Promise); + friends.push(john); + } } else { + // @ts-expect-error john.bestFriends = chris; + // @ts-expect-error chris.bestFriends = john; } } - await settled(); + // give ourselves a tick in case there was async work + await Promise.resolve(); const chrisIdentifier = identifierCache.getOrCreateRecordIdentifier({ type: 'user', id: '1' }); const chrisBestFriend = graph.get(chrisIdentifier, 'bestFriends'); @@ -186,8 +198,8 @@ export async function setInitialState(context: Context, config: TestConfig, asse assert.strictEqual(Object.keys(chrisImplicits).length, 1, 'PreCond: Chris has one implicit relationship'); - const chrisImplicitFriend = chrisImplicits[chrisBestFriend.definition.inverseKey] as ImplicitEdge; - const johnImplicitFriend = johnImplicits[johnBestFriend.definition.inverseKey] as ImplicitEdge; + const chrisImplicitFriend = chrisImplicits[chrisBestFriend.definition.inverseKey]; + const johnImplicitFriend = johnImplicits[johnBestFriend.definition.inverseKey]; assert.ok(chrisImplicitFriend, 'PreCond: Chris has an implicit best friend'); @@ -245,12 +257,12 @@ export async function setInitialState(context: Context, config: TestConfig, asse }; } -export async function testFinalState( +export function testFinalState( context: Context, testState: TestState, config: TestConfig, statuses: ExpectedTestOutcomes, - assert + assert: Assert ) { const { graph, store } = context; const { chrisIdentifier, johnIdentifier } = testState; @@ -260,7 +272,7 @@ export async function testFinalState( // this specific case gets it's own WAT // this is something ideally a refactor should do away with. - const isUnloadOfImplictAsyncHasManyWithLocalChange = + const isUnloadOfImplicitAsyncHasManyWithLocalChange = !!config.isUnloadAsDelete && !!config.dirtyLocal && !!config.async && @@ -273,7 +285,7 @@ export async function testFinalState( // in the dirtyLocal and useCreate case there is no remote data const chrisRemoteRemoved = config.dirtyLocal || config.useCreate || statuses.removed; - const chrisLocalRemoved = statuses.removed && !isUnloadOfImplictAsyncHasManyWithLocalChange; + const chrisLocalRemoved = statuses.removed && !isUnloadOfImplicitAsyncHasManyWithLocalChange; // for the isUnloadAsDelete case we don't remove unless dirtyLocal or useCreate // this may be a bug but likely is related to retaining info for rematerialization. @@ -346,7 +358,7 @@ export async function testFinalState( assert.strictEqual(Object.keys(chrisImplicits).length, 1, 'Result: Chris has one implicit relationship key'); - const chrisImplicitFriend = chrisImplicits[testState.chrisInverseKey] as ImplicitEdge; + const chrisImplicitFriend = chrisImplicits[testState.chrisInverseKey]; assert.ok(chrisImplicitFriend, 'Result: Chris has an implicit relationship for best friend'); const chrisImplicitState = stateOf(store._graph!, chrisImplicitFriend); @@ -370,7 +382,7 @@ export async function testFinalState( assert.false(graph.implicit.has(johnIdentifier), 'implicit cache for john has been removed'); } else { const johnImplicits = graph.getImplicit(johnIdentifier); - const johnImplicitFriend = johnImplicits[testState.johnInverseKey] as ImplicitEdge; + const johnImplicitFriend = johnImplicits[testState.johnInverseKey]; assert.strictEqual( Object.keys(johnImplicits).length, 1, diff --git a/tests/main/tests/helpers/reactive-context.ts b/tests/main/tests/helpers/reactive-context.ts new file mode 100644 index 00000000000..68d1acb3c9d --- /dev/null +++ b/tests/main/tests/helpers/reactive-context.ts @@ -0,0 +1,73 @@ +import type { TestContext } from '@ember/test-helpers'; +import { render } from '@ember/test-helpers'; +import Component from '@glimmer/component'; + +import { hbs } from 'ember-cli-htmlbars'; + +import type Model from '@ember-data/model'; + +export interface ReactiveContext { + counters: Record; + fieldOrder: string[]; + reset: () => void; +} + +export async function reactiveContext( + this: TestContext, + record: T, + fields: { name: string; type: 'field' | 'hasMany' | 'belongsTo' }[] +): Promise { + const _fields: string[] = []; + fields.forEach((field) => { + _fields.push(field.name + 'Count'); + _fields.push(field.name); + }); + + class ReactiveComponent extends Component { + get __allFields() { + return _fields; + } + } + const counters: Record = {}; + + fields.forEach((field) => { + counters[field.name] = 0; + Object.defineProperty(ReactiveComponent.prototype, field.name + 'Count', { + get() { + return counters[field.name]; + }, + }); + Object.defineProperty(ReactiveComponent.prototype, field.name, { + get() { + counters[field.name]++; + switch (field.type) { + case 'hasMany': + return `[${(record[field.name as keyof T] as Model[]).map((r) => r.id).join(',')}]`; + case 'belongsTo': + return (record[field.name as keyof T] as Model).id; + case 'field': + return record[field.name as keyof T] as unknown; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown field type ${field.type} for field ${field.name}`); + } + }, + }); + }); + + this.owner.register('component:reactive-component', ReactiveComponent); + this.owner.register( + 'template:components/reactive-component', + hbs`
    {{#each this.__allFields as |prop|}}
  • {{prop}}: {{get this prop}}
  • {{/each}}
` + ); + + await render(hbs``); + + function reset() { + fields.forEach((field) => { + counters[field.name] = 0; + }); + } + + return { counters, reset, fieldOrder: _fields }; +} diff --git a/tests/main/tests/integration/relationships/collection/mutating-has-many-test.ts b/tests/main/tests/integration/relationships/collection/mutating-has-many-test.ts new file mode 100644 index 00000000000..93c975e1ec9 --- /dev/null +++ b/tests/main/tests/integration/relationships/collection/mutating-has-many-test.ts @@ -0,0 +1,421 @@ +import { settled } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import { DEPRECATE_MANY_ARRAY_DUPLICATES } from '@ember-data/deprecations'; +import Model, { attr, hasMany } from '@ember-data/model'; +import type Store from '@ember-data/store'; +import { recordIdentifierFor } from '@ember-data/store'; +import type { ExistingResourceIdentifierObject } from '@ember-data/types/q/ember-data-json-api'; + +import type { ReactiveContext } from '../../../helpers/reactive-context'; +import { reactiveContext } from '../../../helpers/reactive-context'; + +let IS_DEPRECATE_MANY_ARRAY_DUPLICATES = false; + +if (DEPRECATE_MANY_ARRAY_DUPLICATES) { + IS_DEPRECATE_MANY_ARRAY_DUPLICATES = true; +} + +class User extends Model { + @attr declare name: string; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + @hasMany('user', { async: false, inverse: 'friends' }) declare friends: User[]; +} + +function krystanData() { + return { + id: '2', + type: 'user', + attributes: { + name: 'Krystan', + }, + }; +} + +function krystanRef(): ExistingResourceIdentifierObject { + return { type: 'user', id: '2' }; +} + +function samData() { + return { + id: '3', + type: 'user', + attributes: { + name: 'Sam', + }, + }; +} + +function samRef(): ExistingResourceIdentifierObject { + return { type: 'user', id: '3' }; +} + +function ericData() { + return { + id: '4', + type: 'user', + attributes: { + name: 'Eric', + }, + }; +} + +function ericRef(): ExistingResourceIdentifierObject { + return { type: 'user', id: '4' }; +} + +function chrisData(friends: ExistingResourceIdentifierObject[]) { + return { + id: '1', + type: 'user', + attributes: { + name: 'Chris', + }, + relationships: { + friends: { + data: friends, + }, + }, + }; +} + +function makeUser(store: Store, friends: ExistingResourceIdentifierObject[]): User { + return store.push({ + data: chrisData(friends), + included: [krystanData(), samData(), ericData()], + }) as User; +} + +type Mutation = { + name: string; + method: 'push' | 'unshift' | 'splice'; + values: ExistingResourceIdentifierObject[]; + start?: (record: User) => number; + deleteCount?: (record: User) => number; +}; + +function generateAppliedMutation(store: Store, record: User, mutation: Mutation) { + const friends = record.friends; + let outcomeValues: User[]; + let error: string; + + let seen = new Set(); + const duplicates = new Set(); + let outcome: User[]; + + switch (mutation.method) { + case 'push': + error = "Cannot push duplicates to a hasMany's state."; + outcomeValues = [...friends, ...mutation.values.map((ref) => store.peekRecord(ref) as User)]; + + outcomeValues.forEach((item) => { + if (seen.has(item)) { + duplicates.add(item); + } else { + seen.add(item); + } + }); + + outcome = Array.from(new Set(outcomeValues)); + + break; + case 'unshift': { + error = "Cannot unshift duplicates to a hasMany's state."; + const added = mutation.values.map((ref) => store.peekRecord(ref) as User); + seen = new Set(friends); + outcome = []; + added.forEach((item) => { + if (seen.has(item)) { + duplicates.add(item); + } else { + seen.add(item); + outcome.push(item); + } + }); + outcome.push(...friends); + break; + } + case 'splice': { + const start = mutation.start?.(record) ?? 0; + const deleteCount = mutation.deleteCount?.(record) ?? 0; + outcomeValues = friends.slice(); + const added = mutation.values.map((ref) => store.peekRecord(ref) as User); + outcomeValues.splice(start, deleteCount, ...added); + + if (start === 0 && deleteCount === friends.length) { + error = `Cannot replace a hasMany's state with a new state that contains duplicates.`; + + outcomeValues.forEach((item) => { + if (seen.has(item)) { + duplicates.add(item); + } else { + seen.add(item); + } + }); + + outcome = Array.from(new Set(outcomeValues)); + } else { + error = "Cannot splice a hasMany's state with a new state that contains duplicates."; + + const reducedFriends = friends.slice(); + reducedFriends.splice(start, deleteCount); + seen = new Set(reducedFriends); + const unique: User[] = []; + + added.forEach((item) => { + if (seen.has(item)) { + duplicates.add(item); + } else { + seen.add(item); + unique.push(item); + } + }); + reducedFriends.splice(start, 0, ...unique); + outcome = reducedFriends; + } + break; + } + } + + const hasDuplicates = duplicates.size > 0; + return { + hasDuplicates, + duplicates: Array.from(duplicates), + deduped: { + length: outcome.length, + membership: outcome, + ids: outcome.map((v) => v.id), + }, + unchanged: { + length: friends.length, + membership: friends.slice(), + ids: friends.map((v) => v.id), + }, + error, + }; +} + +async function applyMutation(assert: Assert, store: Store, record: User, mutation: Mutation, rc: ReactiveContext) { + assert.ok(true, `LOG: applying "${mutation.name}" with ids [${mutation.values.map((v) => v.id).join(',')}]`); + + const { counters, fieldOrder } = rc; + const friendsIndex = fieldOrder.indexOf('friends'); + const initialFriendsCount = counters.friends; + if (initialFriendsCount === undefined) { + throw new Error('could not find counters.friends'); + } + + const result = generateAppliedMutation(store, record, mutation); + const initialIds = record.friends.map((f) => f.id).join(','); + + const shouldError = result.hasDuplicates && !IS_DEPRECATE_MANY_ARRAY_DUPLICATES; + const shouldDeprecate = result.hasDuplicates && IS_DEPRECATE_MANY_ARRAY_DUPLICATES; + const expected = shouldError ? result.unchanged : result.deduped; + + try { + switch (mutation.method) { + case 'push': + record.friends.push(...mutation.values.map((ref) => store.peekRecord(ref) as User)); + break; + case 'unshift': + record.friends.unshift(...mutation.values.map((ref) => store.peekRecord(ref) as User)); + break; + case 'splice': + record.friends.splice( + mutation.start?.(record) ?? 0, + mutation.deleteCount?.(record) ?? 0, + ...mutation.values.map((ref) => store.peekRecord(ref) as User) + ); + break; + } + assert.ok(!shouldError, `expected error ${shouldError ? '' : 'NOT '}to be thrown`); + if (shouldDeprecate) { + const expectedMessage = `${ + result.error + } This behavior is deprecated. Found duplicates for the following records within the new state provided to \`.friends\`\n\t- ${Array.from(result.duplicates) + .map((r) => recordIdentifierFor(r).lid) + .sort((a, b) => a.localeCompare(b)) + .join('\n\t- ')}`; + assert.expectDeprecation({ + id: 'ember-data:deprecate-many-array-duplicates', + until: '6.0', + count: 1, + message: expectedMessage, + }); + } + } catch (e) { + assert.ok(shouldError, `expected error ${shouldError ? '' : 'NOT '}to be thrown`); + const expectedMessage = shouldError + ? `${result.error} Found duplicates for the following records within the new state provided to \`.friends\`\n\t- ${Array.from(result.duplicates) + .map((r) => recordIdentifierFor(r).lid) + .sort((a, b) => a.localeCompare(b)) + .join('\n\t- ')}` + : ''; + assert.strictEqual((e as Error).message, expectedMessage, `error thrown has correct message: ${expectedMessage}`); + } + + const expectedIds = expected.ids.join(','); + + assert.strictEqual( + record.friends.length, + expected.length, + `the new state has the correct length of ${expected.length} after ${mutation.method}` + ); + assert.deepEqual( + record.friends.slice(), + expected.membership, + `the new state has the correct records [${expectedIds}] after ${mutation.method} (had [${record.friends + .map((f) => f.id) + .join(',')}])` + ); + assert.deepEqual( + record.hasMany('friends').ids(), + expected.ids, + `the new state has the correct ids on the reference [${expectedIds}] after ${mutation.method}` + ); + assert.strictEqual( + record.hasMany('friends').ids().length, + expected.length, + `the new state has the correct length on the reference of ${expected.length} after ${mutation.method}` + ); + assert.strictEqual( + record.friends.length, + new Set(record.friends).size, + `the new state has no duplicates after ${mutation.method}` + ); + + await settled(); + + const start = mutation.start?.(record) ?? 0; + const deleteCount = mutation.deleteCount?.(record) ?? 0; + const isReplace = + mutation.method === 'splice' && (deleteCount > 0 || (start === 0 && deleteCount === record.friends.length)); + + if (shouldError || (!isReplace && initialIds === expectedIds)) { + assert.strictEqual(counters.friends, initialFriendsCount, 'reactivity: friendsCount does not increment'); + } else { + assert.strictEqual(counters.friends, initialFriendsCount + 1, 'reactivity: friendsCount increments'); + } + assert + .dom(`li:nth-child(${friendsIndex + 1})`) + .hasText(`friends: [${expectedIds}]`, 'reactivity: friends are rendered'); +} + +function getStartingState() { + return [ + { name: 'empty friends', cb: (store: Store) => makeUser(store, []) }, + { name: '1 friend', cb: (store: Store) => makeUser(store, [krystanRef()]) }, + { name: '2 friends', cb: (store: Store) => makeUser(store, [krystanRef(), samRef()]) }, + ]; +} + +function getValues() { + return [ + { + name: 'with empty array', + values: [], + }, + { + name: 'with NO duplicates (compared to initial remote state)', + values: [ericRef()], + }, + { + name: 'with duplicates NOT present in initial remote state', + values: [ericRef(), ericRef()], + }, + { + name: 'with duplicates present in initial remote state', + values: [krystanRef()], + }, + { + name: 'with all the duplicates', + values: [ericRef(), ericRef(), krystanRef()], + }, + ]; +} + +function generateMutations(baseMutation: Omit): Mutation[] { + return getValues().map((v) => ({ + ...baseMutation, + name: `${baseMutation.name} ${v.name}`, + values: v.values, + })); +} + +function getMutations(): Mutation[] { + return [ + ...generateMutations({ + name: 'push', + method: 'push', + }), + ...generateMutations({ + name: 'unshift', + method: 'unshift', + }), + ...generateMutations({ + name: 'replace', + method: 'splice', + start: () => 0, + deleteCount: (user) => user.friends.length, + }), + ...generateMutations({ + name: 'splice with delete (to beginning)', + method: 'splice', + start: () => 0, + deleteCount: (user) => (user.friends.length === 0 ? 0 : 1), + }), + ...generateMutations({ + name: 'splice (to beginning)', + method: 'splice', + start: () => 0, + deleteCount: () => 0, + }), + ...generateMutations({ + name: 'splice (to middle)', + method: 'splice', + start: (user) => Math.floor(user.friends.length / 2), + deleteCount: () => 0, + }), + ...generateMutations({ + name: 'splice (to end)', + method: 'splice', + start: (user) => user.friends.length, + deleteCount: () => 0, + }), + ]; +} + +module('Integration | Relationships | Collection | Mutation', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('model:user', User); + }); + + getStartingState().forEach((startingState) => { + module(`Starting state: ${startingState.name}`, function () { + getMutations().forEach((mutation) => { + module(`Mutation: ${mutation.name}`, function () { + getMutations().forEach((mutation2) => { + test(`followed by Mutation: ${mutation2.name}`, async function (assert) { + const store = this.owner.lookup('service:store') as Store; + const user = startingState.cb(store); + const rc = await reactiveContext.call(this, user, [{ name: 'friends', type: 'hasMany' }]); + rc.reset(); + + await applyMutation(assert, store, user, mutation, rc); + await applyMutation(assert, store, user, mutation2, rc); + }); + }); + }); + }); + }); + }); +}); diff --git a/tests/main/tests/integration/serializers/embedded-records-mixin-test.js b/tests/main/tests/integration/serializers/embedded-records-mixin-test.js index 2c498c5ac11..35d2cb5dc76 100644 --- a/tests/main/tests/integration/serializers/embedded-records-mixin-test.js +++ b/tests/main/tests/integration/serializers/embedded-records-mixin-test.js @@ -49,7 +49,7 @@ module('integration/embedded-records-mixin', function (hooks) { } hooks.beforeEach(function () { - let { owner } = this; + const { owner } = this; owner.register('model:super-villain', SuperVillain); owner.register('model:home-planet', HomePlanet); @@ -384,7 +384,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with embedded objects of same type, but from separate attributes', async function (assert) { - let { owner } = this; + const { owner } = this; class HomePlanetKlass extends Model { @attr('string') name; @hasMany('super-villain', { inverse: 'homePlanet', async: false }) villains; @@ -497,7 +497,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with multiply-nested belongsTo', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:evil-minion', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -588,7 +588,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with polymorphic hasMany and custom primary key', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillainClass extends Model { @attr('string') firstName; @attr('string') lastName; @@ -684,7 +684,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with polymorphic belongsTo', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillainClass extends Model { @attr('string') firstName; @attr('string') lastName; @@ -754,7 +754,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with polymorphic belongsTo and custom primary key', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillainClass extends Model { @attr('string') firstName; @attr('string') lastName; @@ -833,7 +833,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalize with custom belongsTo primary key', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:evil-minion', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -957,7 +957,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with embedded objects with custom primary key', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:super-villain', RESTSerializer.extend({ @@ -1187,7 +1187,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with embedded objects of same type, but from separate attributes', async function (assert) { - let { owner } = this; + const { owner } = this; class HomePlanetClass extends Model { @attr('string') name; @hasMany('super-villain', { inverse: 'homePlanet', async: false }) villains; @@ -1454,7 +1454,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('normalizeResponse with polymorphic hasMany', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillainClass extends Model { @attr('string') firstName; @@ -1578,39 +1578,35 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123' }); - let secretLab = store.createRecord('secret-lab', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123' }); + const secretLab = store.createRecord('secret-lab', { minionCapacity: 5000, vicinity: 'California, USA', id: '101', }); - let superVillain = store.createRecord('super-villain', { + const superVillain = store.createRecord('super-villain', { id: '1', firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', homePlanet, secretLab, }); - let secretWeapon = store.createRecord('secret-weapon', { + store.createRecord('secret-weapon', { id: '1', name: 'Secret Weapon', superVillain, }); - - superVillain.secretWeapons.push(secretWeapon); - - let evilMinion = store.createRecord('evil-minion', { + store.createRecord('evil-minion', { id: '1', name: 'Evil Minion', superVillain, }); - superVillain.evilMinions.push(evilMinion); const serializer = store.serializerFor('super-villain'); const serializedRestJson = serializer.serialize(superVillain._createSnapshot()); const expectedOutput = { firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', homePlanet: '123', evilMinions: [ { @@ -1628,7 +1624,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serializing relationships with an embedded and without calls super when not attr not present', async function (assert) { - let { owner } = this; + const { owner } = this; let calledSerializeBelongsTo = false; let calledSerializeHasMany = false; @@ -1640,12 +1636,12 @@ module('integration/embedded-records-mixin', function (hooks) { serializeHasMany(snapshot, json, relationship) { calledSerializeHasMany = true; - let key = relationship.key; - let payloadKey = this.keyForRelationship ? this.keyForRelationship(key, 'hasMany') : key; - let schema = this.store.modelFor(snapshot.modelName); - let relationshipType = schema.determineRelationshipType(relationship, store); + const key = relationship.key; + const payloadKey = this.keyForRelationship ? this.keyForRelationship(key, 'hasMany') : key; + const schema = this.store.modelFor(snapshot.modelName); + const relationshipType = schema.determineRelationshipType(relationship, store); // "manyToOne" not supported in ActiveModelSerializer.prototype.serializeHasMany - let relationshipTypes = ['manyToNone', 'manyToMany', 'manyToOne']; + const relationshipTypes = ['manyToNone', 'manyToMany', 'manyToOne']; if (relationshipTypes.indexOf(relationshipType) > -1) { json[payloadKey] = snapshot.hasMany(key, { ids: true }); } @@ -1665,41 +1661,38 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123', }); - let secretLab = store.createRecord('secret-lab', { + const secretLab = store.createRecord('secret-lab', { minionCapacity: 5000, vicinity: 'California, USA', id: '101', }); - let superVillain = store.createRecord('super-villain', { + const superVillain = store.createRecord('super-villain', { id: '1', firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', homePlanet, secretLab, }); - let secretWeapon = store.createRecord('secret-weapon', { + store.createRecord('secret-weapon', { id: '1', name: 'Secret Weapon', superVillain, }); - - superVillain.secretWeapons.push(secretWeapon); - let evilMinion = store.createRecord('evil-minion', { + store.createRecord('evil-minion', { id: '1', name: 'Evil Minion', superVillain, }); - superVillain.evilMinions.push(evilMinion); const serializer = store.serializerFor('super-villain'); const serializedRestJson = serializer.serialize(superVillain._createSnapshot()); const expectedOutput = { firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', homePlanet: '123', evilMinions: [ { @@ -1729,7 +1722,7 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123', }); @@ -1773,7 +1766,7 @@ module('integration/embedded-records-mixin', function (hooks) { }, }) ); - let homePlanet = store.createRecord('home-planet', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123', }); @@ -1826,7 +1819,7 @@ module('integration/embedded-records-mixin', function (hooks) { }, }); const serializer = store.serializerFor('home-planet'); - let league = store.peekRecord('home-planet', 123); + const league = store.peekRecord('home-planet', 123); let serializedRestJson; const expectedOutput = { name: 'Villain League', @@ -1850,7 +1843,7 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123', }); @@ -1880,7 +1873,7 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { + const homePlanet = store.createRecord('home-planet', { name: 'Villain League', id: '123', }); @@ -1920,30 +1913,27 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let superVillain = store.createRecord('super-villain', { + const superVillain = store.createRecord('super-villain', { id: '1', firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', }); - let evilMinion = store.createRecord('evil-minion', { + store.createRecord('evil-minion', { id: '1', name: 'Evil Minion', superVillain, }); - let secretWeapon = store.createRecord('secret-weapon', { + store.createRecord('secret-weapon', { id: '1', name: 'Secret Weapon', superVillain, }); - superVillain.evilMinions.push(evilMinion); - superVillain.secretWeapons.push(secretWeapon); - const serializer = store.serializerFor('super-villain'); const serializedRestJson = serializer.serialize(superVillain._createSnapshot()); const expectedOutput = { firstName: 'Super', - lastName: 'Villian', + lastName: 'Villain', homePlanet: null, evilMinions: [ { @@ -1959,7 +1949,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize has many relationship using the `ids-and-types` strategy', async function (assert) { - let { owner } = this; + const { owner } = this; class NormalMinion extends Model { @attr('string') name; } @@ -1983,15 +1973,15 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let yellowMinion = store.createRecord('yellow-minion', { + const yellowMinion = store.createRecord('yellow-minion', { id: '1', name: 'Yellowy', }); - let redMinion = store.createRecord('red-minion', { + const redMinion = store.createRecord('red-minion', { id: '1', name: 'Reddy', }); - let commanderVillain = store.createRecord('commander-villain', { + const commanderVillain = store.createRecord('commander-villain', { id: '1', name: 'Jeff', minions: [yellowMinion, redMinion], @@ -2017,7 +2007,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serializing embedded hasMany respects remapped attrs key', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:home-planet', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2036,7 +2026,7 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); + const homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); store.createRecord('super-villain', { firstName: 'Ice', lastName: 'Creature', @@ -2062,7 +2052,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serializing ids hasMany respects remapped attrs key', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:home-planet', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2081,8 +2071,8 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); - let superVillain = store.createRecord('super-villain', { + const homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); + const superVillain = store.createRecord('super-villain', { firstName: 'Ice', lastName: 'Creature', homePlanet, @@ -2110,12 +2100,12 @@ module('integration/embedded-records-mixin', function (hooks) { ); // records with an id, persisted - let secretLab = store.createRecord('secret-lab', { + const secretLab = store.createRecord('secret-lab', { minionCapacity: 5000, vicinity: 'California, USA', id: '101', }); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2142,7 +2132,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize with embedded object (polymorphic belongsTo relationship)', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:super-villain', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2162,7 +2152,7 @@ module('integration/embedded-records-mixin', function (hooks) { owner.unregister('model:super-villain'); owner.register('model:super-villain', SuperVillain); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { id: '1', firstName: 'Tom', lastName: 'Dale', @@ -2201,7 +2191,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize with embedded object (belongsTo relationship) works with different primaryKeys', async function (assert) { - let { owner } = this; + const { owner } = this; owner.register( 'serializer:super-villain', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2221,7 +2211,7 @@ module('integration/embedded-records-mixin', function (hooks) { const superVillainSerializer = store.serializerFor('super-villain'); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2267,7 +2257,7 @@ module('integration/embedded-records-mixin', function (hooks) { const serializer = store.serializerFor('super-villain'); // records without ids, new - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', secretLab: store.createRecord('secret-lab', { @@ -2291,7 +2281,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize with embedded object (polymorphic belongsTo relationship) supports serialize:ids', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillain extends Model { @attr('string') firstName; @attr('string') lastName; @@ -2311,7 +2301,7 @@ module('integration/embedded-records-mixin', function (hooks) { owner.unregister('model:super-villain'); owner.register('model:super-villain', SuperVillain); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2336,7 +2326,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize with embedded object (belongsTo relationship) supports serialize:id', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillain extends Model { @attr('string') firstName; @attr('string') lastName; @@ -2357,7 +2347,7 @@ module('integration/embedded-records-mixin', function (hooks) { owner.unregister('model:super-villain'); owner.register('model:super-villain', SuperVillain); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2383,7 +2373,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serialize with embedded object (belongsTo relationship) supports serialize:id in conjunction with deserialize:records', async function (assert) { - let { owner } = this; + const { owner } = this; class SuperVillain extends Model { @attr('string') firstName; @attr('string') lastName; @@ -2404,7 +2394,7 @@ module('integration/embedded-records-mixin', function (hooks) { owner.unregister('model:super-villain'); owner.register('model:super-villain', SuperVillain); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2444,7 +2434,7 @@ module('integration/embedded-records-mixin', function (hooks) { ); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2468,7 +2458,7 @@ module('integration/embedded-records-mixin', function (hooks) { assert.deepEqual(serializedRestJson, expectedOutput, 'We serialized the belongsTo relationships to IDs'); }); - test('serialize with embedded object (belongsTo relationship) supports serialize:id', async function (assert) { + test('serialize with embedded object (belongsTo relationship) supports serialize:id, v2', async function (assert) { this.owner.register( 'serializer:super-villain', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2479,7 +2469,7 @@ module('integration/embedded-records-mixin', function (hooks) { ); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2503,7 +2493,7 @@ module('integration/embedded-records-mixin', function (hooks) { assert.deepEqual(serializedRestJson, expectedOutput, 'We serialized the belongsTo relationships to IDs'); }); - test('serialize with embedded object (belongsTo relationship) supports serialize:id in conjunction with deserialize:records', async function (assert) { + test('serialize with embedded object (belongsTo relationship) supports serialize:id in conjunction with deserialize:records, v2', async function (assert) { this.owner.register( 'serializer:super-villain', RESTSerializer.extend(EmbeddedRecordsMixin, { @@ -2514,7 +2504,7 @@ module('integration/embedded-records-mixin', function (hooks) { ); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2549,7 +2539,7 @@ module('integration/embedded-records-mixin', function (hooks) { ); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2580,7 +2570,7 @@ module('integration/embedded-records-mixin', function (hooks) { this.owner.register('serializer:super-villain', RESTSerializer.extend(EmbeddedRecordsMixin)); // records with an id, persisted - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2614,7 +2604,7 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let tom = store.createRecord('super-villain', { + const tom = store.createRecord('super-villain', { firstName: 'Tom', lastName: 'Dale', id: '1', @@ -2638,7 +2628,7 @@ module('integration/embedded-records-mixin', function (hooks) { }); test('serializing belongsTo correctly removes embedded foreign key', async function (assert) { - let { owner } = this; + const { owner } = this; class SecretWeaponClass extends Model { @attr('string') name; } @@ -2660,8 +2650,8 @@ module('integration/embedded-records-mixin', function (hooks) { owner.register('model:secret-weapon', SecretWeaponClass); owner.register('model:evil-minion', EvilMinionClass); - let secretWeapon = store.createRecord('secret-weapon', { name: 'Secret Weapon' }); - let evilMinion = store.createRecord('evil-minion', { + const secretWeapon = store.createRecord('secret-weapon', { name: 'Secret Weapon' }); + const evilMinion = store.createRecord('evil-minion', { name: 'Evil Minion', secretWeapon, }); @@ -2692,8 +2682,8 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); - let superVillain = store.createRecord('super-villain', { + const homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); + const superVillain = store.createRecord('super-villain', { firstName: 'Ice', lastName: 'Creature', homePlanet, @@ -2723,8 +2713,8 @@ module('integration/embedded-records-mixin', function (hooks) { }) ); - let homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); - let superVillain = store.createRecord('super-villain', { + const homePlanet = store.createRecord('home-planet', { name: 'Hoth' }); + const superVillain = store.createRecord('super-villain', { firstName: 'Ice', lastName: 'Creature', homePlanet, diff --git a/tests/main/tests/integration/snapshot-test.js b/tests/main/tests/integration/snapshot-test.js index 5055084d2c3..504ac3cfe2a 100644 --- a/tests/main/tests/integration/snapshot-test.js +++ b/tests/main/tests/integration/snapshot-test.js @@ -53,9 +53,9 @@ module('integration/snapshot - Snapshot', function (hooks) { } owner.register('model:address', Address); - let newAddress = store.createRecord('address', {}); - let snapshot = newAddress._createSnapshot(); - let expected = { + const newAddress = store.createRecord('address', {}); + const snapshot = newAddress._createSnapshot(); + const expected = { country: 'USA', state: 'CA', street: undefined, @@ -79,8 +79,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.ok(snapshot instanceof Snapshot, 'snapshot is an instance of Snapshot'); }); @@ -97,8 +97,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.strictEqual(snapshot.id, '1', 'id is correct'); assert.strictEqual(snapshot.modelName, 'post', 'modelName is correct'); @@ -138,9 +138,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }); - let identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'post', id: '1' }); - let snapshot = store._fetchManager.createSnapshot(identifier); - let expected = { + const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'post', id: '1' }); + const snapshot = store._fetchManager.createSnapshot(identifier); + const expected = { author: undefined, title: 'Hello World', }; @@ -163,9 +163,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }); - let identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'post', id: '1' }); - let snapshot = store._fetchManager.createSnapshot(identifier); - let expected = { + const identifier = store.identifierCache.getOrCreateRecordIdentifier({ type: 'post', id: '1' }); + const snapshot = store._fetchManager.createSnapshot(identifier); + const expected = { author: undefined, title: 'Hello World', }; @@ -185,8 +185,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.strictEqual(snapshot.attr('title'), 'Hello World', 'snapshot title is correct'); post.set('title', 'Tomster'); @@ -205,8 +205,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.expectAssertion( () => { snapshot.attr('unknown'); @@ -228,10 +228,10 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); - let attributes = snapshot.attributes(); + const attributes = snapshot.attributes(); assert.deepEqual(attributes, { author: undefined, title: 'Hello World' }, 'attributes are returned correctly'); }); @@ -248,11 +248,11 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); + const post = store.peekRecord('post', 1); post.set('title', 'Hello World!'); - let snapshot = post._createSnapshot(); + const snapshot = post._createSnapshot(); - let changes = snapshot.changedAttributes(); + const changes = snapshot.changedAttributes(); assert.deepEqual(changes.title, ['Hello World', 'Hello World!'], 'changed attributes are returned correctly'); }); @@ -269,9 +269,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let comment = store.peekRecord('comment', 1); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const comment = store.peekRecord('comment', 1); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.strictEqual(relationship, undefined, 'relationship is undefined'); }); @@ -302,9 +302,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment = store.peekRecord('comment', 2); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const comment = store.peekRecord('comment', 2); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.strictEqual(relationship, null, 'relationship is unset'); }); @@ -335,9 +335,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment = store.peekRecord('comment', 2); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const comment = store.peekRecord('comment', 2); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.ok(relationship instanceof Snapshot, 'snapshot is an instance of Snapshot'); assert.strictEqual(relationship.id, '1', 'post id is correct'); @@ -372,9 +372,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment = store.peekRecord('comment', 2); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const comment = store.peekRecord('comment', 2); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.ok(relationship instanceof Snapshot, 'snapshot is an instance of Snapshot'); assert.deepEqual(relationship.changedAttributes(), {}, 'changedAttributes are correct'); @@ -406,13 +406,13 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let post = store.peekRecord('post', 1); - let comment = store.peekRecord('comment', 2); + const post = store.peekRecord('post', 1); + const comment = store.peekRecord('comment', 2); post.deleteRecord(); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.strictEqual(relationship, null, 'relationship unset after deleted'); }); @@ -436,9 +436,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let comment = store.peekRecord('comment', 2); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post'); + const comment = store.peekRecord('comment', 2); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post'); assert.strictEqual(relationship, undefined, 'relationship is undefined'); }); @@ -466,7 +466,7 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let comment = store.peekRecord('comment', 2); + const comment = store.peekRecord('comment', 2); assert.strictEqual(comment._createSnapshot().belongsTo('post'), undefined, 'relationship is undefined'); await comment.post; @@ -485,8 +485,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.expectAssertion( () => { @@ -498,13 +498,32 @@ module('integration/snapshot - Snapshot', function (hooks) { }); test('snapshot.belongsTo() returns a snapshot if relationship link has been fetched', async function (assert) { - assert.expect(4); - store.adapterFor('application').findBelongsTo = function (store, snapshot, link, relationship) { - return Promise.resolve({ data: { id: '1', type: 'post', attributes: { title: 'Hello World' } } }); + return Promise.resolve({ + data: { + id: '1', + type: 'post', + attributes: { title: 'Hello World' }, + relationships: { comments: { links: { related: './comments' } } }, + }, + included: [ + { + type: 'comment', + id: '2', + relationships: { + post: { + data: { + type: 'post', + id: '1', + }, + }, + }, + }, + ], + }); }; - store.push({ + const comment = store.push({ data: { type: 'comment', id: '2', @@ -520,36 +539,20 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let comment = store.peekRecord('comment', '2'); - const post = await comment.post; - store.push({ - data: [ - { - type: 'post', - id: '1', - attributes: { - title: 'Hello World', - }, - }, - { - type: 'comment', - id: '2', - attributes: { - body: 'This is comment', - }, - }, - ], - }); + // test preconditions of + const initialCommentSnapshot = comment._createSnapshot(); + const initialBelongsTo = initialCommentSnapshot.belongsTo('post'); + assert.strictEqual(initialBelongsTo, undefined, 'relationship is empty'); - const comments = await post.comments; - comments.push(comment); + // fetch the link + const post = await comment.post; - let postSnapshot = post._createSnapshot(); - let commentSnapshot = comment._createSnapshot(); + const postSnapshot = post._createSnapshot(); + const commentSnapshot = comment._createSnapshot(); - let hasManyRelationship = postSnapshot.hasMany('comments'); - let belongsToRelationship = commentSnapshot.belongsTo('post'); + const hasManyRelationship = postSnapshot.hasMany('comments'); + const belongsToRelationship = commentSnapshot.belongsTo('post'); assert.ok(hasManyRelationship instanceof Array, 'hasMany relationship is an instance of Array'); assert.strictEqual(hasManyRelationship.length, 1, 'hasMany relationship contains related object'); @@ -583,17 +586,17 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let post = store.peekRecord('post', '1'); - let comment = store.peekRecord('comment', '2'); + const post = store.peekRecord('post', '1'); + const comment = store.peekRecord('comment', '2'); const comments = await post.comments; comments.push(comment); - let postSnapshot = post._createSnapshot(); - let commentSnapshot = comment._createSnapshot(); + const postSnapshot = post._createSnapshot(); + const commentSnapshot = comment._createSnapshot(); - let hasManyRelationship = postSnapshot.hasMany('comments'); - let belongsToRelationship = commentSnapshot.belongsTo('post'); + const hasManyRelationship = postSnapshot.hasMany('comments'); + const belongsToRelationship = commentSnapshot.belongsTo('post'); assert.ok(hasManyRelationship instanceof Array, 'hasMany relationship is an instance of Array'); assert.strictEqual(hasManyRelationship.length, 1, 'hasMany relationship contains related object'); @@ -627,16 +630,16 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let post = store.peekRecord('post', 1); - let comment = store.peekRecord('comment', 2); + const post = store.peekRecord('post', 1); + const comment = store.peekRecord('comment', 2); comment.set('post', post); - let postSnapshot = post._createSnapshot(); - let commentSnapshot = comment._createSnapshot(); + const postSnapshot = post._createSnapshot(); + const commentSnapshot = comment._createSnapshot(); - let hasManyRelationship = postSnapshot.hasMany('comments'); - let belongsToRelationship = commentSnapshot.belongsTo('post'); + const hasManyRelationship = postSnapshot.hasMany('comments'); + const belongsToRelationship = commentSnapshot.belongsTo('post'); assert.ok(hasManyRelationship instanceof Array, 'hasMany relationship is an instance of Array'); assert.strictEqual(hasManyRelationship.length, 1, 'hasMany relationship contains related object'); @@ -675,9 +678,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment = store.peekRecord('comment', 2); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post', { id: true }); + const comment = store.peekRecord('comment', 2); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post', { id: true }); assert.strictEqual(relationship, '1', 'relationship ID correctly returned'); }); @@ -708,13 +711,13 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let post = store.peekRecord('post', 1); - let comment = store.peekRecord('comment', 2); + const post = store.peekRecord('post', 1); + const comment = store.peekRecord('comment', 2); post.deleteRecord(); - let snapshot = comment._createSnapshot(); - let relationship = snapshot.belongsTo('post', { id: true }); + const snapshot = comment._createSnapshot(); + const relationship = snapshot.belongsTo('post', { id: true }); assert.strictEqual(relationship, null, 'relationship unset after deleted'); }); @@ -731,9 +734,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.strictEqual(relationship, undefined, 'relationship is undefined'); }); @@ -755,9 +758,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.ok(relationship instanceof Array, 'relationship is an instance of Array'); assert.strictEqual(relationship.length, 0, 'relationship is empty'); @@ -799,14 +802,14 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let post = store.peekRecord('post', 3); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const post = store.peekRecord('post', 3); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.ok(relationship instanceof Array, 'relationship is an instance of Array'); assert.strictEqual(relationship.length, 2, 'relationship has two items'); - let relationship1 = relationship[0]; + const relationship1 = relationship[0]; assert.ok(relationship1 instanceof Snapshot, 'relationship item is an instance of Snapshot'); assert.strictEqual(relationship1.id, '1', 'relationship item id is correct'); @@ -849,15 +852,15 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment1 = store.peekRecord('comment', 1); - let comment2 = store.peekRecord('comment', 2); - let post = store.peekRecord('post', 3); + const comment1 = store.peekRecord('comment', 1); + const comment2 = store.peekRecord('comment', 2); + const post = store.peekRecord('post', 3); comment1.deleteRecord(); comment2.deleteRecord(); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.ok(relationship instanceof Array, 'relationship is an instance of Array'); assert.strictEqual(relationship.length, 0, 'relationship is empty'); @@ -883,9 +886,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments', { ids: true }); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments', { ids: true }); assert.deepEqual(relationship, ['2', '3'], 'relationship IDs correctly returned'); }); @@ -926,15 +929,15 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment1 = store.peekRecord('comment', 1); - let comment2 = store.peekRecord('comment', 2); - let post = store.peekRecord('post', 3); + const comment1 = store.peekRecord('comment', 1); + const comment2 = store.peekRecord('comment', 2); + const post = store.peekRecord('post', 3); comment1.deleteRecord(); comment2.deleteRecord(); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments', { ids: true }); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments', { ids: true }); assert.ok(relationship instanceof Array, 'relationship is an instance of Array'); assert.strictEqual(relationship.length, 0, 'relationship is empty'); @@ -959,9 +962,9 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.strictEqual(relationship, undefined, 'relationship is undefined'); }); @@ -992,11 +995,11 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }); - let post = store.peekRecord('post', 1); + const post = store.peekRecord('post', 1); await post.comments.then((comments) => { - let snapshot = post._createSnapshot(); - let relationship = snapshot.hasMany('comments'); + const snapshot = post._createSnapshot(); + const relationship = snapshot.hasMany('comments'); assert.ok(relationship instanceof Array, 'relationship is an instance of Array'); assert.strictEqual(relationship.length, 1, 'relationship has one item'); @@ -1015,8 +1018,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); assert.expectAssertion( () => { @@ -1165,10 +1168,10 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); - let attributes = []; + const attributes = []; snapshot.eachAttribute((name) => attributes.push(name)); assert.deepEqual(attributes, ['author', 'title'], 'attributes are iterated correctly'); }); @@ -1176,8 +1179,8 @@ module('integration/snapshot - Snapshot', function (hooks) { test('snapshot.eachRelationship() proxies to record', function (assert) { assert.expect(2); - let getRelationships = function (snapshot) { - let relationships = []; + const getRelationships = function (snapshot) { + const relationships = []; snapshot.eachRelationship((name) => relationships.push(name)); return relationships; }; @@ -1200,8 +1203,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, ], }); - let comment = store.peekRecord('comment', 1); - let post = store.peekRecord('post', 2); + const comment = store.peekRecord('comment', 1); + const post = store.peekRecord('post', 2); let snapshot; snapshot = comment._createSnapshot(); @@ -1232,8 +1235,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let comment = store.peekRecord('comment', 1); - let snapshot = comment._createSnapshot(); + const comment = store.peekRecord('comment', 1); + const snapshot = comment._createSnapshot(); snapshot.belongsTo('post'); }); @@ -1262,8 +1265,8 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); snapshot.hasMany('comments'); }); @@ -1280,12 +1283,12 @@ module('integration/snapshot - Snapshot', function (hooks) { }, }, }); - let post = store.peekRecord('post', 1); - let snapshot = post._createSnapshot(); + const post = store.peekRecord('post', 1); + const snapshot = post._createSnapshot(); post.set('title', 'New Title'); - let expected = { + const expected = { data: { attributes: { author: undefined, @@ -1294,7 +1297,7 @@ module('integration/snapshot - Snapshot', function (hooks) { type: 'posts', }, }; - assert.deepEqual(snapshot.serialize(), expected, 'shapshot serializes correctly'); + assert.deepEqual(snapshot.serialize(), expected, 'snapshot serializes correctly'); expected.data.id = '1'; assert.deepEqual(snapshot.serialize({ includeId: true }), expected, 'serialize takes options'); });