diff --git a/ember-data-types/q/record-data-store-wrapper.ts b/ember-data-types/q/record-data-store-wrapper.ts index 90899f90e74..c6749bd32a2 100644 --- a/ember-data-types/q/record-data-store-wrapper.ts +++ b/ember-data-types/q/record-data-store-wrapper.ts @@ -5,6 +5,7 @@ import { StableRecordIdentifier } from './identifier'; import type { RecordData } from './record-data'; import type { AttributesSchema, RelationshipsSchema } from './record-data-schemas'; import { SchemaDefinitionService } from './schema-definition-service'; +import Adapter from "@ember-data/adapter"; /** @module @ember-data/store @@ -250,6 +251,8 @@ export interface V2RecordDataStoreWrapper { recordDataFor(identifier: StableRecordIdentifier): RecordData; notifyChange(identifier: StableRecordIdentifier, namespace: NotificationType, key?: string): void; + + adapterFor(modelName: string): Adapter; } export type RecordDataStoreWrapper = LegacyRecordDataStoreWrapper | V2RecordDataStoreWrapper; diff --git a/ember-data-types/q/record-data.ts b/ember-data-types/q/record-data.ts index d9882d52ee4..7c6a573bcaf 100644 --- a/ember-data-types/q/record-data.ts +++ b/ember-data-types/q/record-data.ts @@ -13,6 +13,14 @@ export interface ChangedAttributesHash { [key: string]: [string, string]; } +export type Delta = { + added: unknown[], + removed: unknown[] +} +export interface ChangedRelationshipsHash { + [key: string]: Delta; +} + export interface MergeOperation { op: 'mergeIdentifiers'; record: StableRecordIdentifier; // existing @@ -87,17 +95,23 @@ export interface RecordData { getAttr(identifier: StableRecordIdentifier, propertyName: string): unknown; setAttr(identifier: StableRecordIdentifier, propertyName: string, value: unknown): void; + shouldDirtyAttr(identifier: StableRecordIdentifier, propertyName: string, oldValue: unknown, newValue: unknown): boolean; + isAttrDirty(identifier: StableRecordIdentifier, propertyName: string): boolean; + hasDirtyAttrs(identifier: StableRecordIdentifier): boolean; changedAttrs(identifier: StableRecordIdentifier): ChangedAttributesHash; hasChangedAttrs(identifier: StableRecordIdentifier): boolean; rollbackAttrs(identifier: StableRecordIdentifier): string[]; // Relationships // ============= - getRelationship( - identifier: StableRecordIdentifier, - propertyName: string - ): SingleResourceRelationship | CollectionResourceRelationship; + getRelationship(identifier: StableRecordIdentifier, propertyName: string): SingleResourceRelationship | CollectionResourceRelationship; update(operation: LocalRelationshipOperation): void; + shouldDirtyRelationship(identifier: StableRecordIdentifier, propertyName: string, newValue: unknown): boolean; + isRelationshipDirty(identifier: StableRecordIdentifier, propertyName: string): boolean; + hasDirtyRelationships(identifier: StableRecordIdentifier): boolean; + changedRelationships(identifier: StableRecordIdentifier): ChangedRelationshipsHash; + hasChangedRelationships(identifier: StableRecordIdentifier): boolean; + rollbackRelationships(identifier: StableRecordIdentifier): string[]; // State // ============= diff --git a/packages/adapter/addon/index.ts b/packages/adapter/addon/index.ts index 948780846e1..0b7ca2181e0 100644 --- a/packages/adapter/addon/index.ts +++ b/packages/adapter/addon/index.ts @@ -820,7 +820,7 @@ export default class Adapter extends EmberObject implements MinimumAdapterInterf @return {Boolean} @public */ - shouldBackgroundReloadRecord(store: Store, Snapshot): boolean { + shouldBackgroundReloadRecord(store: Store, snapshot: Snapshot): boolean { return true; } @@ -860,6 +860,51 @@ export default class Adapter extends EmberObject implements MinimumAdapterInterf shouldBackgroundReloadAll(store: Store, snapshotRecordArray: SnapshotRecordArray): boolean { return true; } + + /** + This is used by the store to determine if the store should remove deleted + records from relationships prior to save. + + If this is `true` records will remain part of any associated relationships + after being deleted prior to being saved. + + If this is `false` records will be removed from any associated relationships + immediately after being deleted. + + By default, this is `false`. + + @since 4.8.0 + */ + shouldRemoveDeletedFromRelationshipsPriorToSave: boolean = false; + + // shouldDirtyAttribute(internalModel, context, value) { + // return value !== context.originalValue; + // }, + // + // shouldDirtyBelongsTo(internalModel, context, value) { + // return value !== context.originalValue; + // }, + // + // shouldDirtyHasMany(internalModel, context, value) { + // let relationshipType = internalModel.type.determineRelationshipType({ + // key: context.key, + // kind: context.kind + // }, internalModel.store); + // + // if (relationshipType === 'manyToNone') { + // if (context.added) { + // return !context.originalValue.has(context.added); + // } + // return context.originalValue.has(context.removed); + // } else if (relationshipType === 'manyToMany') { + // const { canonicalMembers, members } = internalModel._relationships.get(context.key); + // if (canonicalMembers.size !== members.size) { + // return true; + // } + // return !canonicalMembers.list.every(x => members.list.includes(x)); + // } + // return false; + // } } export { BuildURLMixin } from './-private'; diff --git a/packages/model/addon/-private/legacy-relationships-support.ts b/packages/model/addon/-private/legacy-relationships-support.ts index 9c54bd6ef9d..56695b68eb6 100644 --- a/packages/model/addon/-private/legacy-relationships-support.ts +++ b/packages/model/addon/-private/legacy-relationships-support.ts @@ -688,7 +688,7 @@ function handleCompletedRelationshipRequest( } if (isHasMany) { - (value as RelatedCollection).isLoaded = true; + (value as RelatedCollection).isLoaded = (relationship as ManyRelationship).isLoaded = true; } relationship.state.hasFailedLoadAttempt = false; diff --git a/packages/model/addon/-private/model.js b/packages/model/addon/-private/model.js index 65cbe638f57..4a9b6f7e334 100644 --- a/packages/model/addon/-private/model.js +++ b/packages/model/addon/-private/model.js @@ -268,6 +268,10 @@ class Model extends EmberObject { get hasDirtyAttributes() { return this.currentState.isDirty; } + @dependentKeyCompat + get isDirty() { + return this.currentState.isDirty0; + } /** If this property is `true` the record is in the `saving` state. A @@ -863,6 +867,27 @@ class Model extends EmberObject { }); } + changedRelationships() { + return recordDataFor(this).changedRelationships(recordIdentifierFor(this)); + } + + rollbackRelationships() { + return recordDataFor(this).rollbackRelationships(recordIdentifierFor(this)); + } + + changes() { + const changes = Object.create(null); + for (const [key, [oldValue, newValue]] of Object.entries(this.changedAttributes())) { + changes[key] = { added: newValue === null ? [] : [newValue], removed: oldValue === null ? [] : [oldValue] } + } + return Object.assign(changes, this.changedRelationships()); + } + + rollback() { + this.rollbackRelationships(); + this.rollbackAttributes(); + } + /** @method _createSnapshot @private diff --git a/packages/model/addon/-private/record-state.ts b/packages/model/addon/-private/record-state.ts index 71eab95d4ec..68182e9b27a 100644 --- a/packages/model/addon/-private/record-state.ts +++ b/packages/model/addon/-private/record-state.ts @@ -217,6 +217,7 @@ export default class RecordState { this.fulfilledCount++; this.notify('isLoading'); this.notify('isDirty'); + this.notify('isDirty0'); notifyErrorsStateChanged(this); this._errorRequests = []; this._lastError = null; @@ -244,11 +245,15 @@ export default class RecordState { this.notify('isNew'); this.notify('isDeleted'); this.notify('isDirty'); + this.notify('isDirty0'); break; case 'attributes': this.notify('isEmpty'); this.notify('isDirty'); break; + case 'relationships': + this.notify('isDirty0'); + break; case 'errors': this.updateInvalidErrors(this.record.errors); this.notify('isValid'); @@ -369,6 +374,15 @@ export default class RecordState { return this.isNew || rd.hasChangedAttrs(this.identifier); } + @tagged + get isDirty0() { + let rd = this.recordData; + if (rd.isDeletionCommitted(this.identifier) || (this.isDeleted && this.isNew)) { + return false; + } + return this.isNew || this.isDeleted || rd.hasDirtyAttrs(this.identifier) || rd.hasDirtyRelationships(this.identifier); + } + @tagged get isError() { let errorReq = this._errorRequests[this._errorRequests.length - 1]; diff --git a/packages/record-data/addon/-private/graph/graph.ts b/packages/record-data/addon/-private/graph/graph.ts index 7974cccb690..14e379a9aeb 100644 --- a/packages/record-data/addon/-private/graph/graph.ts +++ b/packages/record-data/addon/-private/graph/graph.ts @@ -7,9 +7,10 @@ import { MergeOperation } from '@ember-data/types/q/record-data'; import type { RecordDataStoreWrapper } from '@ember-data/types/q/record-data-store-wrapper'; import type { Dict } from '@ember-data/types/q/utils'; +import { ImplicitRelationship } from '../relationships/state/relationships'; import BelongsToRelationship from '../relationships/state/belongs-to'; import ManyRelationship from '../relationships/state/has-many'; -import type { EdgeCache, UpgradedMeta } from './-edge-definition'; +import type { EdgeCache } from './-edge-definition'; import { isLHS, upgradeDefinition } from './-edge-definition'; import type { DeleteRecordOperation, @@ -35,13 +36,7 @@ import replaceRelatedRecord from './operations/replace-related-record'; import replaceRelatedRecords, { syncRemoteToLocal } from './operations/replace-related-records'; import updateRelationshipOperation from './operations/update-relationship'; -export interface ImplicitRelationship { - definition: UpgradedMeta; - identifier: StableRecordIdentifier; - localMembers: Set; - remoteMembers: Set; -} - +export { ImplicitRelationship }; export type RelationshipEdge = ImplicitRelationship | ManyRelationship | BelongsToRelationship; export const Graphs = new Map(); @@ -96,6 +91,15 @@ export class Graph { this._removing = null; } + all(identifier: StableRecordIdentifier): Dict { + let relationships = this.identifiers.get(identifier); + if (relationships === undefined) { + relationships = Object.create(null) as Dict; + this.identifiers.set(identifier, relationships); + } + return relationships; + } + has(identifier: StableRecordIdentifier, propertyName: string): boolean { let relationships = this.identifiers.get(identifier); if (!relationships) { @@ -106,12 +110,7 @@ export class Graph { get(identifier: StableRecordIdentifier, propertyName: string): RelationshipEdge { assert(`expected propertyName`, propertyName); - let relationships = this.identifiers.get(identifier); - if (!relationships) { - relationships = Object.create(null) as Dict; - this.identifiers.set(identifier, relationships); - } - + const relationships = this.all(identifier); let relationship = relationships[propertyName]; if (!relationship) { const info = upgradeDefinition(this, identifier, propertyName); @@ -122,12 +121,7 @@ export class Graph { const Klass = meta.kind === 'hasMany' ? ManyRelationship : BelongsToRelationship; relationship = relationships[propertyName] = new Klass(meta, identifier); } else { - relationship = relationships[propertyName] = { - definition: meta, - identifier, - localMembers: new Set(), - remoteMembers: new Set(), - }; + relationship = relationships[propertyName] = new ImplicitRelationship(meta, identifier); } } @@ -468,6 +462,10 @@ function destroyRelationship(graph: Graph, rel: RelationshipEdge, silenceNotific notifyChange(graph, rel.identifier, rel.definition.key); } } + + if (isHasMany(rel)) { + rel.isLoaded = false; + } } function notifyInverseOfDematerialization( diff --git a/packages/record-data/addon/-private/record-data.ts b/packages/record-data/addon/-private/record-data.ts index cea58c41a96..3b1c8902b90 100644 --- a/packages/record-data/addon/-private/record-data.ts +++ b/packages/record-data/addon/-private/record-data.ts @@ -11,14 +11,16 @@ import type { SingleResourceRelationship, } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; -import type { ChangedAttributesHash, MergeOperation, RecordData } from '@ember-data/types/q/record-data'; +import type { ChangedAttributesHash, ChangedRelationshipsHash, MergeOperation, RecordData } from '@ember-data/types/q/record-data'; import type { AttributesHash, JsonApiResource, JsonApiValidationError } from '@ember-data/types/q/record-data-json-api'; import { AttributeSchema, RelationshipSchema } from '@ember-data/types/q/record-data-schemas'; import { RecordDataStoreWrapper, V2RecordDataStoreWrapper } from '@ember-data/types/q/record-data-store-wrapper'; import { Dict } from '@ember-data/types/q/utils'; import { LocalRelationshipOperation } from './graph/-operations'; -import { isImplicit } from './graph/-utils'; +import { isBelongsTo, isHasMany, isImplicit } from './graph/-utils'; +import { Graph } from "./graph/graph"; +import { addToInverse, removeFromInverse } from "./graph/operations/replace-related-records"; import { graphFor, peekGraph } from './graph/index'; import type BelongsToRelationship from './relationships/state/belongs-to'; import type ManyRelationship from './relationships/state/has-many'; @@ -384,7 +386,7 @@ export default class SingletonRecordData implements RecordData { : cached.remoteAttrs && attr in cached.remoteAttrs ? cached.remoteAttrs[attr] : undefined; - if (existing !== value) { + if (this.shouldDirtyAttr(identifier, attr, existing, value)) { cached.localAttrs = cached.localAttrs || Object.create(null); cached.localAttrs![attr] = value; cached.changes = cached.changes || Object.create(null); @@ -392,17 +394,32 @@ export default class SingletonRecordData implements RecordData { } else if (cached.localAttrs) { delete cached.localAttrs[attr]; delete cached.changes![attr]; + if (Object.keys(cached.localAttrs).length === 0) { + cached.localAttrs = null; + } } this.__storeWrapper.notifyChange(identifier, 'attributes', attr); } + shouldDirtyAttr(identifier: StableRecordIdentifier, propertyName: string, oldValue: unknown, newValue: unknown): boolean { + return oldValue !== newValue; + } + isAttrDirty(identifier: StableRecordIdentifier, propertyName: string): boolean { + const { localAttrs } = this.__peek(identifier); + if (localAttrs === null) { + return false; + } + return propertyName in localAttrs; + } + hasDirtyAttrs(identifier: StableRecordIdentifier): boolean { + return this.hasChangedAttrs(identifier); + } changedAttrs(identifier: StableRecordIdentifier): ChangedAttributesHash { // TODO freeze in dev return this.__peek(identifier).changes || Object.create(null); } hasChangedAttrs(identifier: StableRecordIdentifier): boolean { - const cached = this.__peek(identifier, true); - return cached.localAttrs !== null && Object.keys(cached.localAttrs).length > 0; + return this.__peek(identifier, true).localAttrs !== null; } rollbackAttrs(identifier: StableRecordIdentifier): string[] { const cached = this.__peek(identifier); @@ -447,11 +464,90 @@ export default class SingletonRecordData implements RecordData { ): SingleResourceRelationship | CollectionResourceRelationship { return (graphFor(this.__storeWrapper).get(identifier, field) as BelongsToRelationship | ManyRelationship).getData(); } + shouldDirtyRelationship(identifier: StableRecordIdentifier, propertyName: string, newValue: unknown): boolean { + return false; + } + isRelationshipDirty(identifier: StableRecordIdentifier, propertyName: string): boolean { + const relationship = graphFor(this.__storeWrapper).get(identifier, propertyName); + return relationship.isDirty; + } + hasDirtyRelationships(identifier: StableRecordIdentifier): boolean { + const relationships = graphFor(this.__storeWrapper).all(identifier); + return Object.values(relationships).some(r => r?.isDirty); + } + changedRelationships(identifier: StableRecordIdentifier): ChangedRelationshipsHash { + const relationships = graphFor(this.__storeWrapper).all(identifier); + const changes = Object.create(null); + for (const [key, relationship] of Object.entries(relationships)) { + if (relationship === undefined) continue; + const delta = relationship.delta + if (delta.added.length > 0 || delta.removed.length > 0) { + changes[key] = delta; + } + } + return changes; + } + hasChangedRelationships(identifier: StableRecordIdentifier): boolean { + const relationships = graphFor(this.__storeWrapper).all(identifier); + for (const relationship of Object.values(relationships)) { + if (relationship === undefined) continue; + const delta = relationship.delta + if (delta.added.length > 0 || delta.removed.length > 0) { + return true; + } + } + return false; + } + rollbackRelationships(identifier: StableRecordIdentifier): string[] { + const changes = this.changedRelationships(identifier); + const keysWithChanges: string[] = Object.keys(changes); + if (this.isNew(identifier)) { + removeLocallyFromInverseRelationships(graphFor(this.__storeWrapper), identifier); + this.unloadRecord(identifier); + } else if (this.isDeleted(identifier)) { + this.setIsDeleted(identifier, false); + } else { // restore inverse relationships + const relationships = graphFor(this.__storeWrapper).all(identifier); + for (const [key, relationship] of Object.entries(relationships)) { + if (!(key in changes) || relationship === undefined) continue; + if (isBelongsTo(relationship)) { + this.update({ + op: 'replaceRelatedRecord', + record: identifier, + field: key, + value: relationship.remoteState + }); + } else if (isHasMany(relationship)) { + this.update({ + op: 'replaceRelatedRecords', + record: identifier, + field: key, + value: relationship.remoteState + }); + } + } + } + + this.__storeWrapper.notifyChange(identifier, 'state'); + + if (keysWithChanges.length > 0) { + notifyRelationships(this.__storeWrapper, identifier, keysWithChanges); + } + + return keysWithChanges; + } setIsDeleted(identifier: StableRecordIdentifier, isDeleted: boolean): void { + const adapter = this.__storeWrapper.adapterFor(identifier.type); const cached = this.__peek(identifier); cached.isDeleted = isDeleted; - if (cached.isNew) { + if (adapter.shouldRemoveDeletedFromRelationshipsPriorToSave) { + if (isDeleted) { + removeLocallyFromInverseRelationships(graphFor(this.__storeWrapper), identifier); + } else { + addLocallyToInverseRelationships(graphFor(this.__storeWrapper), identifier); + } + } else if (cached.isNew) { // TODO can we delete this since we will do this in unload? graphFor(this.__storeWrapper).push({ op: 'deleteRecord', @@ -531,6 +627,16 @@ function notifyAttributes(storeWrapper: RecordDataStoreWrapper, identifier: Stab storeWrapper.notifyChange(identifier, 'attributes', keys[i]); } } +function notifyRelationships(storeWrapper: RecordDataStoreWrapper, identifier: StableRecordIdentifier, keys?: string[]) { + if (!keys) { + storeWrapper.notifyChange(identifier, 'relationships'); + return; + } + + for (let i = 0; i < keys.length; i++) { + storeWrapper.notifyChange(identifier, 'relationships', keys[i]); + } +} /* TODO @deprecate IGOR DAVID @@ -617,6 +723,9 @@ function patchLocalAttributes(cached: CachedResource): boolean { hasAppliedPatch = true; delete localAttrs[attr]; delete changes![attr]; + if (Object.keys(localAttrs).length === 0) { + cached.localAttrs = null; + } } } return hasAppliedPatch; @@ -716,3 +825,104 @@ function _allRelatedRecordDatas( return array; } + +function addLocallyToInverseRelationships(graph: Graph, identifier: StableRecordIdentifier): void { + for (const relationship of Object.values(graph.all(identifier))) { + if (relationship === undefined) continue; + if (isBelongsTo(relationship)) { + if (relationship.localState === null) throw new Error('Invalid state: Inverse relationship with null value.'); + addToInverse(graph, relationship.localState, relationship.definition.inverseKey, identifier, false); + //graph.update({ + // op: 'replaceRelatedRecord', + // record: identifier, + // field: key, + // value: identifier + //}, false); + //relationship.localState = identifier; + //// This allows dematerialized inverses to be rematerialized + //// we shouldn't be notifying here though, figure out where + //// a notification was missed elsewhere. + ////if (!silenceNotifications) { + //// notifyChange(graph, relationship.identifier, relationship.definition.key); + ////} + } else if (isHasMany(relationship)) {if (relationship.localState.length === 0) throw new Error('Invalid state: Inverse relationship is empty.'); + for (const localState of relationship.localState) { + addToInverse(graph, localState, relationship.definition.inverseKey, identifier, false); + } + //graph.update({ + // op: 'addToRelatedRecords', + // record: identifier, + // field: key, + // value: identifier + //}, false); + //relationship.localMembers.add(identifier); + // + //const index = relationship.localState.indexOf(identifier); + //if (index !== -1) { + // relationship.localState.splice(index, 0, identifier); + // // This allows dematerialized inverses to be rematerialized + // // we shouldn't be notifying here though, figure out where + // // a notification was missed elsewhere. + // //if (!silenceNotifications) { + // // notifyChange(graph, relationship.identifier, relationship.definition.key); + // //} + //} + } else { + for (const localMember of [...relationship.localMembers]) { + addToInverse(graph, localMember, relationship.definition.inverseKey, identifier, false); + } + } + } +} + +function removeLocallyFromInverseRelationships(graph: Graph, identifier: StableRecordIdentifier): void { + for (const relationship of Object.values(graph.all(identifier))) { + if (relationship === undefined) continue; + if (isBelongsTo(relationship)) { + if (relationship.localState === null) throw new Error('Invalid state: Inverse relationship with null value.'); + removeFromInverse(graph, relationship.localState, relationship.definition.inverseKey, identifier, false); + //graph.update({ + // op: 'replaceRelatedRecord', + // record: identifier, + // field: key, + // value: null + //}, false); + //if (relationship.localState === identifier) { + // relationship.localState = null; + // // This allows dematerialized inverses to be rematerialized + // // we shouldn't be notifying here though, figure out where + // // a notification was missed elsewhere. + // //if (!silenceNotifications) { + // // notifyChange(graph, relationship.identifier, relationship.definition.key); + // //} + //} + } else if (isHasMany(relationship)) { + if (relationship.localState.length === 0) throw new Error('Invalid state: Inverse relationship is empty.'); + for (const localState of relationship.localState) { + removeFromInverse(graph, localState, relationship.definition.inverseKey, identifier, false); + } + //graph.update({ + // op: 'removeFromRelatedRecords', + // record: identifier, + // field: key, + // value: identifier + //}, false); + //relationship.localMembers.delete(identifier); + // + //const index = relationship.localState.indexOf(identifier); + //if (index !== -1) { + // relationship.localState.splice(index, 1); + // // This allows dematerialized inverses to be rematerialized + // // we shouldn't be notifying here though, figure out where + // // a notification was missed elsewhere. + // //if (!silenceNotifications) { + // // notifyChange(graph, relationship.identifier, relationship.definition.key); + // //} + //} + } else { + for (const localMember of [...relationship.localMembers]) { + removeFromInverse(graph, localMember, relationship.definition.inverseKey, identifier, false); + } + } + } +} diff --git a/packages/record-data/addon/-private/relationships/state/belongs-to.ts b/packages/record-data/addon/-private/relationships/state/belongs-to.ts index 0fedeff4e0e..b4d3a2451b9 100644 --- a/packages/record-data/addon/-private/relationships/state/belongs-to.ts +++ b/packages/record-data/addon/-private/relationships/state/belongs-to.ts @@ -1,32 +1,32 @@ -import type { Links, Meta, PaginationLinks, SingleResourceRelationship } from '@ember-data/types/q/ember-data-json-api'; +import type { SingleResourceRelationship } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { Delta } from "@ember-data/types/q/record-data"; import type { UpgradedMeta } from '../../graph/-edge-definition'; import type { RelationshipState } from '../../graph/-state'; import { createState } from '../../graph/-state'; +import { ExplicitRelationship } from "./relationships"; -export default class BelongsToRelationship { - declare definition: UpgradedMeta; - declare identifier: StableRecordIdentifier; - declare _state: RelationshipState | null; - declare transactionRef: number; - - declare localState: StableRecordIdentifier | null; - declare remoteState: StableRecordIdentifier | null; - declare meta: Meta | null; - declare links: Links | PaginationLinks | null; +export default class BelongsToRelationship extends ExplicitRelationship { constructor(definition: UpgradedMeta, identifier: StableRecordIdentifier) { - this.definition = definition; - this.identifier = identifier; - this._state = null; - this.transactionRef = 0; + super(definition, identifier); + this.remoteState = null; + this.localState = null; + } - this.meta = null; - this.links = null; + get delta(): Delta { + return this.isDirty ? { + added: this.localState === null ? [] : [this.localState], + removed: this.remoteState === null ? [] : [this.remoteState] + } : { + added: [], + removed: [] + } + } - this.localState = null; - this.remoteState = null; + get isDirty() { + return this.localState !== this.remoteState; } get state(): RelationshipState { diff --git a/packages/record-data/addon/-private/relationships/state/has-many.ts b/packages/record-data/addon/-private/relationships/state/has-many.ts index 603358ffd9b..14f73e9a705 100755 --- a/packages/record-data/addon/-private/relationships/state/has-many.ts +++ b/packages/record-data/addon/-private/relationships/state/has-many.ts @@ -1,45 +1,40 @@ -import type { - CollectionResourceRelationship, - Links, - Meta, - PaginationLinks, -} from '@ember-data/types/q/ember-data-json-api'; +import type { CollectionResourceRelationship } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; +import { Delta } from "@ember-data/types/q/record-data"; import type { UpgradedMeta } from '../../graph/-edge-definition'; import type { RelationshipState } from '../../graph/-state'; import { createState } from '../../graph/-state'; +import { ExplicitRelationship } from "./relationships"; -export default class ManyRelationship { - declare definition: UpgradedMeta; - declare identifier: StableRecordIdentifier; - declare _state: RelationshipState | null; - declare transactionRef: number; - - declare localMembers: Set; +export default class ManyRelationship extends ExplicitRelationship { declare remoteMembers: Set; - declare meta: Meta | null; - declare links: Links | PaginationLinks | null; - - declare remoteState: StableRecordIdentifier[]; - declare localState: StableRecordIdentifier[]; + declare localMembers: Set; + declare isLoaded: boolean; constructor(definition: UpgradedMeta, identifier: StableRecordIdentifier) { - this.definition = definition; - this.identifier = identifier; - this._state = null; - this.transactionRef = 0; - - this.localMembers = new Set(); + super(definition, identifier); + this.remoteState = []; this.remoteMembers = new Set(); + this.localState = []; + this.localMembers = new Set(); + this.isLoaded = false; + } - this.meta = null; - this.links = null; + get delta(): Delta { + return { + added: [...this.localMembers].filter(m => !this.remoteMembers.has(m)), + removed: [...this.remoteMembers].filter(m => !this.localMembers.has(m)) + } + } - // persisted state - this.remoteState = []; - // local client state - this.localState = []; + get isDirty() { + if ((this.links !== null && !this.isLoaded) || this.definition.inverseKind === 'belongsTo') { + return false; + } + return this.localState.length !== this.remoteState.length + || this.localState.some(m => !this.remoteMembers.has(m)) + || this.remoteState.some(m => !this.localMembers.has(m)); } get state(): RelationshipState { diff --git a/packages/record-data/addon/-private/relationships/state/relationships.ts b/packages/record-data/addon/-private/relationships/state/relationships.ts new file mode 100644 index 00000000000..8d711e5453d --- /dev/null +++ b/packages/record-data/addon/-private/relationships/state/relationships.ts @@ -0,0 +1,84 @@ +import { Links, Meta, PaginationLinks } from "@ember-data/types/q/ember-data-json-api"; +import { StableRecordIdentifier } from "@ember-data/types/q/identifier"; +import { Delta } from "@ember-data/types/q/record-data"; +import { Dict } from "@ember-data/types/q/utils"; + +import { createState, RelationshipState } from "../../graph/-state"; +import { UpgradedMeta } from "../../graph/-edge-definition"; +import { Value as JSONValue } from "json-typescript"; + +type ResourceRelationship = { + data?: unknown; + meta?: Dict; + links?: Links; +} + +export interface Relationship { + get delta(): Delta; + get isDirty(): boolean; +} + +export abstract class ExplicitRelationship implements Relationship { + + declare public remoteState: T; + declare public localState: T; + + public transactionRef: number; + public definition: UpgradedMeta; + public identifier: StableRecordIdentifier; + public meta: Meta | null; + public links: Links | PaginationLinks | null; + + protected _state: RelationshipState | null; + + protected constructor(definition: UpgradedMeta, identifier: StableRecordIdentifier) { + this.transactionRef = 0; + this.definition = definition; + this.identifier = identifier; + this.meta = null; + this.links = null; + this._state = null; + } + + abstract get delta(): Delta; + + abstract get isDirty(): boolean; + + get state(): RelationshipState { + let { _state } = this; + if (!_state) { + _state = this._state = createState(); + } + return _state; + } + + abstract getData(): ResourceRelationship; +} + +export class ImplicitRelationship implements Relationship { + declare definition: UpgradedMeta; + declare identifier: StableRecordIdentifier; + declare remoteMembers: Set; + declare localMembers: Set; + + constructor(definition: UpgradedMeta, identifier: StableRecordIdentifier) { + this.definition = definition; + this.identifier = identifier; + this.remoteMembers = new Set(); + this.localMembers = new Set(); + } + + get delta(): Delta { + return { + added: [...this.localMembers].filter(m => !this.remoteMembers.has(m)), + removed: [...this.remoteMembers].filter(m => !this.localMembers.has(m)) + } + } + + get isDirty() { + debugger + return this.localMembers.size !== this.remoteMembers.size + || [...this.localMembers].some(m => !this.remoteMembers.has(m)) + || [...this.remoteMembers].some(m => !this.localMembers.has(m)); + } +} diff --git a/packages/store/addon/-private/managers/record-data-manager.ts b/packages/store/addon/-private/managers/record-data-manager.ts index 27f7f2b8f9d..05231932099 100644 --- a/packages/store/addon/-private/managers/record-data-manager.ts +++ b/packages/store/addon/-private/managers/record-data-manager.ts @@ -6,7 +6,7 @@ import type { SingleResourceRelationship, } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; -import type { ChangedAttributesHash, MergeOperation, RecordData, RecordDataV1 } from '@ember-data/types/q/record-data'; +import type { ChangedAttributesHash, ChangedRelationshipsHash, MergeOperation, RecordData, RecordDataV1 } from '@ember-data/types/q/record-data'; import type { JsonApiResource, JsonApiValidationError } from '@ember-data/types/q/record-data-json-api'; import type { Dict } from '@ember-data/types/q/utils'; @@ -794,6 +794,18 @@ export class SingletonRecordDataManager implements RecordData { this.#recordData(identifier).setAttr(identifier, propertyName, value); } + shouldDirtyAttr(identifier: StableRecordIdentifier, propertyName: string, oldValue: unknown, newValue: unknown): boolean { + return this.#recordData(identifier).shouldDirtyAttr(identifier, propertyName, oldValue, newValue); + } + + isAttrDirty(identifier: StableRecordIdentifier, propertyName: string): boolean { + return this.#recordData(identifier).isAttrDirty(identifier, propertyName); + } + + hasDirtyAttrs(identifier: StableRecordIdentifier): boolean { + return this.#recordData(identifier).hasDirtyAttrs(identifier); + } + changedAttrs(identifier: StableRecordIdentifier): ChangedAttributesHash { return this.#recordData(identifier).changedAttrs(identifier); } @@ -806,6 +818,9 @@ export class SingletonRecordDataManager implements RecordData { return this.#recordData(identifier).rollbackAttrs(identifier); } + // Relationships + // ============= + getRelationship( identifier: StableRecordIdentifier, propertyName: string @@ -816,6 +831,30 @@ export class SingletonRecordDataManager implements RecordData { this.#recordData(operation.record).update(operation); } + shouldDirtyRelationship(identifier: StableRecordIdentifier, propertyName: string, newValue: unknown): boolean { + return this.#recordData(identifier).shouldDirtyRelationship(identifier, propertyName, newValue); + } + + isRelationshipDirty(identifier: StableRecordIdentifier, propertyName: string): boolean { + return this.#recordData(identifier).isRelationshipDirty(identifier, propertyName); + } + + hasDirtyRelationships(identifier: StableRecordIdentifier): boolean { + return this.#recordData(identifier).hasDirtyRelationships(identifier); + } + + changedRelationships(identifier: StableRecordIdentifier): ChangedRelationshipsHash { + return this.#recordData(identifier).changedRelationships(identifier); + } + + hasChangedRelationships(identifier: StableRecordIdentifier): boolean { + return this.#recordData(identifier).hasChangedRelationships(identifier); + } + + rollbackRelationships(identifier: StableRecordIdentifier): string[] { + return this.#recordData(identifier).rollbackRelationships(identifier); + } + // State // ============= diff --git a/packages/store/addon/-private/managers/record-data-store-wrapper.ts b/packages/store/addon/-private/managers/record-data-store-wrapper.ts index 39a5926f6a9..a2d95fa576e 100644 --- a/packages/store/addon/-private/managers/record-data-store-wrapper.ts +++ b/packages/store/addon/-private/managers/record-data-store-wrapper.ts @@ -16,6 +16,7 @@ import coerceId from '../utils/coerce-id'; import constructResource from '../utils/construct-resource'; import normalizeModelName from '../utils/normalize-model-name'; import { NotificationType } from './record-notification-manager'; +import Adapter from "@ember-data/adapter"; /** @module @ember-data/store @@ -419,6 +420,10 @@ class V2RecordDataStoreWrapper implements StoreWrapper { this._store._instanceCache.disconnect(identifier); this._pendingNotifies.delete(identifier); } + + adapterFor(modelName: string): Adapter { + return this._store.adapterFor(modelName) as Adapter; + } } export type RecordDataStoreWrapper = LegacyWrapper | V2RecordDataStoreWrapper; diff --git a/tests/main/package.json b/tests/main/package.json index fa6cdc0f6ea..04e4a5c48ef 100644 --- a/tests/main/package.json +++ b/tests/main/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "ember build", - "test": "ember test --test-port=0", + "test": "ember test --server --test-port=0", "test:try-one": "ember try:one" }, "author": "", diff --git a/tests/main/tests/integration/record-array-test.js b/tests/main/tests/integration/record-array-test.js index 9e513e4a930..f74b2dc879a 100644 --- a/tests/main/tests/integration/record-array-test.js +++ b/tests/main/tests/integration/record-array-test.js @@ -183,6 +183,40 @@ module('unit/record-array - RecordArray', function (hooks) { assert.strictEqual(recordArray.length, 0, 'record is removed from the array when it is saved'); }); + test('a loaded record is removed from a record array when it is deleted (remove deleted prior to save)', async function (assert) { + assert.expect(5); + this.owner.register('adapter:application', class extends Adapter { + deleteRecord() { + return resolve({ data: null }); + } + shouldBackgroundReloadRecord() { + return false; + } + shouldRemoveDeletedFromRelationshipsPriorToSave = true; + }); + + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Scumbag Dale' } }, + { type: 'person', id: '2', attributes: { name: 'Scumbag Katz' } }, + { type: 'person', id: '3', attributes: { name: 'Scumbag Bryn' } }, + { type: 'tag', id: '1' } + ] + }); + + let scumbag = store.peekRecord('person', 1); + let tag = store.peekRecord('tag', 1); + let people = await tag.people; + people.push(scumbag); + assert.equal(await scumbag.tag, tag, "precond - the scumbag's tag has been set"); + assert.equal(people.length, 1, 'precond - record array has one item'); + assert.equal(people.at(0)?.name, 'Scumbag Dale', 'item at index 0 is record with id 1'); + scumbag.deleteRecord(); + assert.equal(people.length, 0, 'record is removed from the record array'); + await scumbag.save(); + assert.equal(people.length, 0, 'record is still removed from the array when it is saved'); + }); + test("a loaded record is not removed from a relationship ManyArray when it is deleted even if the belongsTo side isn't defined", async function (assert) { class Person extends Model { @attr() @@ -290,6 +324,39 @@ module('unit/record-array - RecordArray', function (hooks) { assert.strictEqual(tool.person, scumbag, 'the tool still belongs to the record'); }); + test("a loaded record is not removed from both the record array and from the belongs to, even if the belongsTo side isn't defined (remove deleted prior to save)", async function(assert) { + assert.expect(4); + this.owner.register( + 'adapter:application', + class extends Adapter { + deleteRecord() { + return Promise.resolve({ data: null }); + } + shouldRemoveDeletedFromRelationshipsPriorToSave = true; + } + ); + + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Scumbag Tom' } }, + { type: 'tag', id: '1', relationships: { people: { data: [{ type: 'person', id: '1' }] } } }, + { type: 'tool', id: '1', relationships: { person: { data: { type: 'person', id: '1' } } } } + ], + }); + + let scumbag = store.peekRecord('person', 1); + let tag = store.peekRecord('tag', 1); + let tool = store.peekRecord('tool', 1); + + assert.equal(tag.people.length, 1, 'person is in the record array'); + assert.equal(tool.person, scumbag, 'the tool belongs to the person'); + + scumbag.deleteRecord(); + + assert.equal(tag.people.length, 0, 'person is not in the record array'); + assert.equal(tool.person, null, 'the tool does not belong to the person'); + }); + // GitHub Issue #168 test('a newly created record is removed from a record array when it is deleted', async function (assert) { let recordArray = store.peekAll('person'); diff --git a/tests/main/tests/integration/relationships/belongs-to-test.js b/tests/main/tests/integration/relationships/belongs-to-test.js index 02199d3c0a7..3d541a6d900 100644 --- a/tests/main/tests/integration/relationships/belongs-to-test.js +++ b/tests/main/tests/integration/relationships/belongs-to-test.js @@ -1152,6 +1152,26 @@ module('integration/relationship/belongs_to Belongs-To Relationships', function assert.strictEqual(book.author, author, 'Book has an author after rollback attributes'); }); + test('Rollbacking for a deleted record restores implicit relationship - async (remove deleted prior to save)', async function(assert) { + let store = this.owner.lookup('service:store'); + let adapter = store.adapterFor('application'); + adapter.shouldRemoveDeletedFromRelationshipsPriorToSave = true; + + class BookWithImplicitInverse extends Book { + @belongsTo('author', { async: true, inverse: null }) author; + } + this.owner.register('model:book', BookWithImplicitInverse); + + let book, author; + book = store.push({ data: { id: '1', type: 'book', attributes: { name: "Stanley's Amazing Adventures" }, relationships: { author: { data: { id: '2', type: 'author' } } } } }); + author = store.push({ data: { id: '2', type: 'author', attributes: { name: 'Stanley' } } }); + author.deleteRecord(); + author.rollback(); + assert.equal(await book.author, author, 'Book has an author after rollback'); + + adapter.shouldRemoveDeletedFromRelationshipsPriorToSave = false; + }); + testInDebug('Passing a model as type to belongsTo should not work', function (assert) { assert.expect(2); diff --git a/tests/main/tests/integration/relationships/many-to-many-test.js b/tests/main/tests/integration/relationships/many-to-many-test.js index 9fd23c24d16..41b38a96ae5 100644 --- a/tests/main/tests/integration/relationships/many-to-many-test.js +++ b/tests/main/tests/integration/relationships/many-to-many-test.js @@ -1,5 +1,7 @@ /*eslint no-unused-vars: ["error", { "varsIgnorePattern": "(ada)" }]*/ +import { run } from '@ember/runloop'; + import { get } from '@ember/object'; import { settled } from '@ember/test-helpers'; @@ -486,6 +488,158 @@ module('integration/relationships/many_to_many_test - ManyToMany relationships', assert.strictEqual(user.accounts.length, 0, 'Accounts got rolledback correctly'); }); + /* Relationship isDirty Tests */ + + test('Relationship isDirty at correct times when removing and adding back values', async function (assert) { + let store = this.owner.lookup('service:store'); + let user, topics, topic1, topic2, changes; + + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { topics: { data: [{ type: 'topic', id: 1 }] } } } }); + // NOTE SB Pushing topics into store (even with updated values) should not dirty the user relationship + topic1 = store.push({ data: { type: 'topic', id: 1, attributes: { title: "This year's EmberFest was great" } } }); + topic2 = store.push({ data: { type: 'topic', id: 2, attributes: { title: "Last year's EmberFest was great" } } }); + topics = await user.topics; + changes = user.changedRelationships(); + assert.equal('topics' in changes, false, 'pushing topic1 into store does not create a user change'); + assert.equal(user.isDirty, false, 'pushing topic1 into store does not make user dirty'); + + topics.splice(topics.indexOf(topic1), 1); + changes = user.changedRelationships(); + assert.equal('topics' in changes, true, 'removing topic1 creates a user change'); + assert.equal(user.isDirty, true, 'removing topic1 makes the user dirty'); + + topics.push(topic1, topic2); + changes = user.changedRelationships(); + assert.equal('topics' in changes, true, 'adding topic1 and topic2 updates the user change'); + assert.equal(user.isDirty, true, 'adding topic1 and topic2 keeps the user dirty'); + + topics.splice(topics.indexOf(topic2), 1); + changes = user.changedRelationships(); + assert.equal('topics' in changes, false, 'removing topic2 deletes the user change'); + assert.equal(user.isDirty, false, 'removing topic2 makes the user not dirty'); + }); + + test('Relationship isDirty at correct times for inverse relationships using links depending on fetched or not', async function (assert) { + let store = this.owner.lookup('service:store'); + let adapter = store.adapterFor('application'); + + const user1Data = { id: 1, type: 'user', attributes: { name: 'Stanley' }, relationships: { topics: { links: { related: '/users/1/topics' } } } }; + const topic1Data = { id: 1, type: 'topic', attributes: { title: "This year's EmberFest was great" }, relationships: { users: { links: { related: '/topics/1/users' } } } }; + const topic2Data = { id: 2, type: 'topic', attributes: { title: "Last year's EmberFest was great" }, relationships: { users: { links: { related: '/topics/2/users' } } } }; + + adapter.findHasMany = function (store, record, link) { + if (link === '/users/1/topics') { + return Promise.resolve({ + data: [topic1Data], + }); + } else if (link === '/topics/1/users') { + return Promise.resolve({ + data: [user1Data], + }); + } else if (link === '/topics/2/users') { + return Promise.resolve({ + data: [], + }); + } + throw new Error('Invalid usage of test.'); + }; + + let user, topics, topic1, topic2; + user = store.push({ data: user1Data }); + // NOTE SB Pushing topics into store (even with updated values) should not dirty the user relationship + topic1 = store.push({ data: topic1Data }); + topic2 = store.push({ data: topic2Data }); + + topics = await user.topics; + topics.splice(topics.indexOf(topic1), 1); + assert.equal(user.isDirty, true, 'removing topic1 dirties the user'); + assert.equal(topic1.isDirty, false, 'removing topic1 does not dirty topic1 of the unresolved inverse relationship'); + topics.push(topic1, topic2); + assert.equal(topic2.isDirty, false, 'adding topic2 does not dirty topic2 of the unresolved inverse relationship'); + topics.splice(topics.indexOf(topic2), 1); + assert.equal(topic1.isDirty, false, 'topic1 is not dirty since we are back to original state'); + assert.equal(topic2.isDirty, false, 'topic2 is not dirty since we are back to original state'); + await topic2.users; + topics.push(topic2); + assert.equal(topic2.isDirty, true, 'topic2 does dirty since its inverse is resolved'); + topics.splice(topics.indexOf(topic2), 1); + assert.equal(topic2.isDirty, false, 'removing topic2 makes the inverse topic2 not dirty again'); + await topic1.users; + topics.splice(topics.indexOf(topic1), 1); + assert.equal(topic1.isDirty, true, 'removing topic1 does dirty topic1 of the resolved inverse relationship'); + topics.push(topic1); + assert.equal(topic1.isDirty, false, 'removing topic1 makes the inverse topic1 not dirty again'); + }); + + test('Relationship is changed and is dirtied correctly while adding and removing values (async+data)', async function (assert) { + let store = this.owner.lookup('service:store'); + let user, topics, topic1, topic2, changes; + + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { topics: { data: [{ type: 'topic', id: 1 }] } } } }); + // NOTE SB Pushing topics into store (even with updated values) does not dirty the user relationship + topic1 = store.push({ data: { type: 'topic', id: 1, attributes: { title: "This year's EmberFest was great" } } }); + topic2 = store.push({ data: { type: 'topic', id: 2, attributes: { title: "Last year's EmberFest was great" } } }); + topics = await user.topics; + assert.equal('topics' in user.changedRelationships(), false, 'user has no changes after pushing topics into store'); + assert.equal(user.isDirty, false, 'user is not dirty after pushing topics into store'); + + topics.push(topic2); + changes = user.changedRelationships(); + assert.equal('topics' in changes, true, 'user has changes after adding topic2 to topics'); + assert.deepEqual(changes.topics.added.map(_ => _.id), [topic2.id], 'user changes reflect topic2 added to topics'); + assert.deepEqual(changes.topics.removed, [], 'user changes reflect nothing removed from topics'); + assert.equal(user.isDirty, true, 'user is dirty after adding topic2 to topics'); + + topics.splice(topics.indexOf(topic1), 1); + topics.splice(topics.indexOf(topic2), 1); + changes = user.changedRelationships(); + assert.equal('topics' in changes, true, 'user has changes after removing topic1 and topic2 from topics'); + assert.deepEqual(changes.topics.added, [], 'user changes reflect nothing added to topics'); + assert.deepEqual(changes.topics.removed.map(_ => _.id), [topic1.id], 'user changes reflect topic1 removed from topics'); + assert.equal(user.isDirty, true, 'user is dirty after removing topic1 and topic2 from topics'); + + topics.push(topic1); + changes = user.changedRelationships(); + assert.equal('topics' in changes, false, 'user has no changes after adding back topic1 to topics'); + assert.equal(user.isDirty, false, 'user is not dirty after adding topic1 back to topics'); + }); + + /* Rollback Relationships Tests */ + + test('Rollback many-to-many relationships works correctly - async', async function (assert) { + let store = this.owner.lookup('service:store'); + let users, user, topic1, topic2, topics; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { topics: { data: [{ type: 'topic', id: 1 }] } } } }); + topic1 = store.push({ data: { type: 'topic', id: 1, attributes: { title: "This year's EmberFest was great" } } }); + topic2 = store.push({ data: { type: 'topic', id: 2, attributes: { title: "Last year's EmberFest was great" } } }); + users = await topic2.users; + users.push(user); + topic2.rollback(); + users = await topic1.users; + assert.deepEqual(users, [user], 'Users are still there'); + users = await topic2.users; + assert.deepEqual(users, [], 'Users are still empty'); + topics = await user.topics; + assert.deepEqual(topics, [topic1], 'Topics are still there'); + }); + + test('Rollback many-to-many relationships works correctly - sync', function (assert) { + let store = this.owner.lookup('service:store'); + let users, user, account1, account2, accounts; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { accounts: { data: [{ type: 'account', id: 1 }] } } } }); + account1 = store.push({ data: { type: 'account', id: 1, attributes: { state: 'lonely' } } }); + account2 = store.push({ data: { type: 'account', id: 2, attributes: { state: 'content' } } }); + users = account2.users; + users.push(user); + account2.rollback(); + accounts = user.accounts; + assert.deepEqual(accounts, [account1], 'Accounts are still there'); + users = account1.users; + assert.deepEqual(users, [user], 'Users are still there'); + users = account2.users; + assert.deepEqual(users, [], 'Users are still empty'); + }); + todo( 'Re-loading a removed record should re add it to the relationship when the removed record is the last one in the relationship', function (assert) { diff --git a/tests/main/tests/integration/relationships/one-to-many-test.js b/tests/main/tests/integration/relationships/one-to-many-test.js index 138ee64cad4..7092d15dbc7 100644 --- a/tests/main/tests/integration/relationships/one-to-many-test.js +++ b/tests/main/tests/integration/relationships/one-to-many-test.js @@ -1644,4 +1644,142 @@ module('integration/relationships/one_to_many_test - OneToMany relationships', f assert.expectDeprecation({ id: 'ember-data:deprecate-array-like', count: 3 }); } ); + + /* Adding to the Many side (Parent) of OneToMany should dirty the Child */ + + test('Adding to the Many side (Parent) of OneToMany should dirty the Child', async function (assert) { + let store = this.owner.lookup('service:store'); + let user, message, messages; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + store.push({ data: { type: 'message', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message = store.push({ data: { type: 'message', id: 2, relationships: { user: { data: null } } } }); + messages = await user.messages; + messages.push(message); + assert.equal(await message.user, user, 'child has newly assigned parent'); + assert.equal(message.isDirty, true, 'message (child) is dirtied when it has a new user (parent)'); + assert.equal(user.isDirty, false, 'user (parent) is not dirty when its gets a new message (child)'); + }); + + /* Rollback from Dirty State */ + + test('Rollback one-to-many relationships when the hasMany side has changed (async+data)', async function (assert) { + let store = this.owner.lookup('service:store'); + let user, message1, message2, messages, changes; + + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + message1 = store.push({ data: { type: 'message', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message2 = store.push({ data: { type: 'message', id: 2, relationships: { user: { data: null } } } }); + + message2.user = user; + changes = message2.changedRelationships(); + assert.equal('user' in changes, true, 'message2 has changes after replacing user'); + assert.deepEqual(changes.user.added.map(_ => _.id), [user.id], 'user changes reflect user replaced null'); + assert.deepEqual(changes.user.removed, [], 'user changes reflect nothing removed from topics'); + assert.equal(message2.isDirty, true, 'message2 is dirty after replacing user'); + + message2.rollback(); + assert.equal(await message2.user, null, 'message2.user is null after rollback'); + + messages = await user.messages; + assert.equal(messages.length, 1, 'user.messages has one message after rollback'); + assert.deepEqual(messages, [message1], 'user.messages has contains only message1 after rollback'); + }); + + test('Rollback one-to-many relationships when the hasMany side has changed - sync', function (assert) { + let store = this.owner.lookup('service:store'); + let user, account1, account2, accounts; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + account1 = store.push({ data: { type: 'account', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + account2 = store.push({ data: { type: 'account', id: 2, relationships: { user: { data: null } } } }); + account2.user = user; + account2.rollback(); + assert.equal(account2.user, null, 'Account does not have the user anymore'); + accounts = user.accounts; + assert.equal(accounts.length, 1, 'User does not have the account anymore'); + assert.deepEqual(accounts, [account1], 'User only has the original account'); + }); + + test('Rollback one-to-many relationships when the belongsTo side has changed - async', async function (assert) { + let store = this.owner.lookup('service:store'); + let user, message1, message2, message3, message4, message5, message6, message7, message8, message9, messages; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + message1 = store.push({ data: { type: 'message', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message2 = store.push({ data: { type: 'message', id: 2, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message3 = store.push({ data: { type: 'message', id: 3, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message4 = store.push({ data: { type: 'message', id: 4, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message5 = store.push({ data: { type: 'message', id: 5, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + message6 = store.push({ data: { type: 'message', id: 6, relationships: { user: { data: null } } } }); + message7 = store.push({ data: { type: 'message', id: 7, relationships: { user: { data: null } } } }); + message8 = store.push({ data: { type: 'message', id: 8, relationships: { user: { data: null } } } }); + message9 = store.push({ data: { type: 'message', id: 9, relationships: { user: { data: null } } } }); + messages = await user.messages; + messages.push(message8); + messages.push(message6); + messages.splice(messages.indexOf(message3), 1); + messages.push(message9); + messages.push(message7); + messages.splice(messages.indexOf(message1), 1); + messages.splice(messages.indexOf(message5), 1); + messages.push(message3); + [message1, message3, message5, message6, message7, message8, message9].map(m => m.rollback()); + assert.equal(await message8.user, null, 'Message 8 does not belong to the user'); + assert.equal(await message6.user, null, 'Message 6 does not belong to the user'); + assert.equal(await message9.user, null, 'Message 9 does not belong to the user'); + assert.equal(await message7.user, null, 'Message 7 does not belong to the user'); + assert.equal(await message1.user, user, 'Message 1 does belong to the user'); + assert.equal(await message5.user, user, 'Message 5 does belong to the user'); + assert.equal(await message3.user, user, 'Message 3 does belong to the user'); + messages = await user.messages; + assert.deepEqual(messages.sort((a,b) => a.id - b.id), [message1, message2, message3, message4, message5], 'User still has the original 5 messages'); + }); + + test('Rollback one-to-many relationships when the belongsTo side has changed - sync', function (assert) { + let store = this.owner.lookup('service:store'); + let user, account1, account2, accounts; + user = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' } } }); + account1 = store.push({ data: { type: 'account', id: 1, relationships: { user: { data: { type: 'user', id: 1 } } } } }); + account2 = store.push({ data: { type: 'account', id: 2, relationships: { user: { data: null } } } }); + accounts = user.accounts; + accounts.push(account2); + account2.rollback(); + assert.equal(account1.user, user, 'Account 1 still has the user'); + assert.equal(account2.user, null, 'Account 2 still does not have the user'); + assert.deepEqual(user.accounts, [account1], 'User only has the original account'); + }); + + test('Async hasMany does not fetch from remote after already fetched', async function (assert) { + let store = this.owner.lookup('service:store'); + let adapter = store.adapterFor('application'); + + const user1Data = { id: 1, type: 'user', attributes: { name: 'Stanley' }, relationships: { messages: { links: { related: '/users/1/messages' } } } }; + const message1Data = { id: 1, type: 'message', attributes: { title: "This year's EmberFest was great" }, relationships: { user: { data: { id: '1', type: 'user' } } } }; + let fetchCount = 0; + + adapter.findHasMany = function (store, record, link) { + if (link === '/users/1/messages') { + assert.equal(fetchCount++ < 1, true, 'user 1 messages is fetched only once'); + return resolve({ + data: [message1Data], + }); + } + throw new Error('Invalid usage of test.'); + }; + adapter.shouldRemoveDeletedFromRelationshipsPriorToSave = true; + + let user, messages; + user = store.push({ data: user1Data }); + messages = await user.messages; + assert.equal(messages.length, 1, 'start out with 1 message'); + messages.at(0).deleteRecord(); + assert.equal(messages.length, 0, 'down to 0 messages'); + messages = await user.messages; + assert.equal(messages.length, 0, 'should still be at 0 messages'); + messages.createRecord(); + messages.createRecord(); + assert.equal(messages.length, 2, 'back up to 2 with the new messages'); + messages = await user.messages; + assert.equal(messages.length, 2, 'should still be at 2 messages'); + + adapter.shouldRemoveDeletedFromRelationshipsPriorToSave = false; + }); }); diff --git a/tests/main/tests/integration/relationships/one-to-one-test.js b/tests/main/tests/integration/relationships/one-to-one-test.js index 1dafa625beb..05b1323e290 100644 --- a/tests/main/tests/integration/relationships/one-to-one-test.js +++ b/tests/main/tests/integration/relationships/one-to-one-test.js @@ -1028,4 +1028,32 @@ module('integration/relationships/one_to_one_test - OneToOne relationships', fun assert.strictEqual(user.job, null, 'Job got rollbacked correctly'); assert.true(job.isDestroyed, 'Job is destroyed'); }); + + /* Rollback Relationships Tests */ + + test('Rollback one-to-one relationships restores both sides of the relationship - async', async function (assert) { + let store = this.owner.lookup('service:store'); + let stanley, bob, jim; + stanley = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { bestFriend: { data: { type: 'user', id: 2 } } } } }); + bob = store.push({ data: { type: 'user', id: 2, name: "Stanley's friend" } }); + jim = store.push({ data: { type: 'user', id: 3, name: "Stanley's other friend" } }); + stanley.bestFriend = jim; + stanley.rollback(); + assert.equal(await stanley.bestFriend, bob, "Stanley's bestFriend is still Bob"); + assert.equal(await bob.bestFriend, stanley, "Bob's bestFriend is still Stanley"); + assert.equal(await jim.bestFriend, null, 'Jim still has no bestFriend'); + }); + + test('Rollback one-to-one relationships restores both sides of the relationship - sync', function (assert) { + let store = this.owner.lookup('service:store'); + let job, stanley, bob; + job = store.push({ data: { type: 'job', id: 2, attributes: { isGood: true } } }); + stanley = store.push({ data: { type: 'user', id: 1, attributes: { name: 'Stanley' }, relationships: { job: { data: { type: 'job', id: 2 } } } } }); + bob = store.push({ data: { type: 'user', id: 2, attributes: { name: 'Bob' } } }); + job.user = bob; + job.rollback(); + assert.equal(stanley.job, job, 'Stanley still has a job'); + assert.equal(bob.job, null, 'Bob still has no job'); + assert.equal(job.user, stanley, 'The job still belongs to Stanley'); + }); }); diff --git a/tests/main/tests/unit/model/relationships/rollback-test.js b/tests/main/tests/unit/model/relationships/rollback-test.js new file mode 100644 index 00000000000..45308ec6c0d --- /dev/null +++ b/tests/main/tests/unit/model/relationships/rollback-test.js @@ -0,0 +1,231 @@ +import { run } from '@ember/runloop'; + +import { module, test } from 'qunit'; +import { resolve } from 'rsvp'; + +import { setupTest } from 'ember-qunit'; + +import Adapter from '@ember-data/adapter'; +import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; +import JSONAPISerializer from '@ember-data/serializer/json-api'; + +module('unit/model/relationships/rollback - model.rollback()', function(hooks) { + let store, adapter; + setupTest(hooks); + + class Person extends Model { + @attr() firstName; + @attr() lastName; + @hasMany('dog', { async: true, inverse: 'owner' }) dogs; + } + + class Dog extends Model { + @attr() name; + @belongsTo('person', { async: true, inverse: 'dogs' }) owner; + } + + hooks.beforeEach(function() { + let { owner } = this; + owner.register('model:person', Person); + owner.register('model:dog', Dog); + owner.register('adapter:application', Adapter.extend()); + owner.register('serializer:application', JSONAPISerializer.extend()); + + store = owner.lookup('service:store'); + adapter = store.adapterFor('application'); + }); + + test('saved changes to relationships should not roll back to a pre-saved state (from child)', async function (assert) { + let person1, person2, dog1, dog2, dog3, dogs, owner; + + adapter.updateRecord = function() { + return resolve({ data: { type: 'dog', id: 2, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + }; + + store.push({ data: {type: 'person', id: 1, attributes: { firstName: 'Tom', lastName: 'Dale' } } }); + store.push({ data: { type: 'person', id: 2, attributes: { firstName: 'John', lastName: 'Doe' } } }); + store.push({ data: { type: 'dog', id: 1, attributes: { name: 'Fido' }, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + store.push({ data: { type: 'dog', id: 2, attributes: { name: 'Bear' }, relationships: { owner: { data: { type: 'person', id: 2 } } } } }); + store.push({ data: { type: 'dog', id: 3, attributes: { name: 'Spot' } } }); + person1 = store.peekRecord('person', 1); + person2 = store.peekRecord('person', 2); + dog1 = store.peekRecord('dog', 1); + dog2 = store.peekRecord('dog', 2); + dog3 = store.peekRecord('dog', 3); + dogs = await person1.dogs; + dogs.push(dog2); + await dog2.save(); + dogs = await person1.dogs; + dogs.push(dog3); + dog2.rollback(); + dog3.rollback(); + dogs = await person1.dogs; + assert.deepEqual(dogs, [dog1, dog2]); + dogs = await person2.dogs; + assert.deepEqual(dogs, []); + owner = await dog1.owner; + assert.equal(owner, person1); + owner = await dog2.owner; + assert.equal(owner, person1); + }); + + test('additions, removals and deletions to a hasMany relationship can be rolled back', async function (assert) { + let tom, dog2, dog3, dogs; + + store.adapterFor('dog').shouldRemoveDeletedFromRelationshipsPriorToSave = true; + + store.push({ data: { type: 'person', id: 1, attributes: { firstName: 'Tom', lastName: 'Dale' } } }); + store.push({ data: { type: 'dog', id: 1, attributes: { name: 'Fido' }, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + store.push({ data: { type: 'dog', id: 2, attributes: { name: 'Bear' }, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + store.push({ data: { type: 'dog', id: 3, attributes: { name: 'Spot' } } }); + tom = store.peekRecord('person', 1); + dog2 = store.peekRecord('dog', 2); + dog3 = store.peekRecord('dog', 3); + + dogs = await tom.dogs; + assert.equal(dogs.length, 2, 'Tom has has 2 dogs'); + dogs.push(dog3); + assert.equal(dogs.length, 3, 'Tom now has 3 dogs'); + dog3.rollback(); + assert.equal(dogs.length, 2, 'Tom is restored to having 2 dogs'); + + dogs.splice(dogs.indexOf(dog2), 1); + assert.equal(dogs.length, 1, 'Tom now only has 1 dog, after one ran away'); + dog2.rollback(); + assert.equal(dogs.length, 2, 'Tom is restored to having 2 dogs'); + + dog2.deleteRecord(); + assert.equal(dog2.isDirty, true, 'dog is dirtied when its deleted'); + assert.equal(dogs.length, 1, 'Tom now only has 1 dog, after one got ran over by a car'); + dog2.rollback(); + assert.equal(dogs.length, 2, 'Tom is restored to having 2 dogs'); + + store.adapterFor('dog').shouldRemoveDeletedFromRelationshipsPriorToSave = false; + }); + + //skip("saved changes to relationships should not roll back to a pre-saved state (from parent)", function(assert) { + // var person1, person2, dog1, dog2, dog3; + // + // adapter.updateRecord = function(store, type, snapshot) { + // return resolve({ id: 1, dogs: [1] }); + // }; + // + // run(function() { + // store.push({ + // data: { + // type: 'person', + // id: 1, + // attributes: { + // firstName: "Tom", + // lastName: "Dale" + // }, + // relationships: { + // dogs: { + // data: [{ + // type: 'dog', + // id: 1 + // }] + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'person', + // id: 2, + // attributes: { + // firstName: "John", + // lastName: "Doe" + // }, + // relationships: { + // dogs: { + // data: [{ + // type: 'dog', + // id: 2 + // }] + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 1, + // attributes: { + // name: "Fido" + // }, + // relationships: { + // owner: { + // data: { + // type: 'person', + // id: 1 + // } + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 2, + // attributes: { + // name: "Bear" + // }, + // relationships: { + // owner: { + // data: { + // type: 'person', + // id: 2 + // } + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 3, + // attributes: { + // name: "Spot" + // }, + // relationships: { + // owner: { + // data: null + // } + // } + // } + // }); + // person1 = store.peekRecord('person', 1); + // person2 = store.peekRecord('person', 2); + // dog1 = store.peekRecord('dog', 1); + // dog2 = store.peekRecord('dog', 2); + // dog3 = store.peekRecord('dog', 3); + // + // person1.get('dogs').push(dog2); + // }); + // + // run(function() { + // person1.save().then(function () { + // person1.get('dogs').push(dog3); + // return Ember.RSVP.all([person1.rollback()]); + // }).then(function () { + // person1.get('dogs').then(function (dogs) { + // assert.deepEqual(dogs.toArray(), [dog1,dog2]); + // }); + // person2.get('dogs').then(function (dogs) { + // assert.deepEqual(dogs.toArray(), []); + // }); + // dog1.get('owner').then(function (owner) { + // assert.equal(owner, person1); + // }).then(function () { + // console.log(person1._internalModel._relationships.get('dogs').manyArray.currentState.map(function (i) { return i.id; })); + // console.log(dog2._internalModel._relationships.get('owner').get('id')); + // console.log(dog3._internalModel._relationships.get('owner').get('id')); + // }); + // dog2.get('owner').then(function (owner) { + // assert.equal(owner, person1); + // }); + // }); + // }); + //}); +}); diff --git a/tests/main/tests/unit/model/rollback-test.js b/tests/main/tests/unit/model/rollback-test.js new file mode 100644 index 00000000000..547c5a40519 --- /dev/null +++ b/tests/main/tests/unit/model/rollback-test.js @@ -0,0 +1,205 @@ +import { run } from '@ember/runloop'; + +import { module, test } from 'qunit'; +import { resolve } from 'rsvp'; + +import { setupTest } from 'ember-qunit'; + +import Adapter from '@ember-data/adapter'; +import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; +import JSONAPISerializer from '@ember-data/serializer/json-api'; + +module('unit/model/relationships/rollback - model.rollback()', async function (hooks) { + let store, adapter; + setupTest(hooks); + + class Person extends Model { + @attr() firstName; + @attr() lastName; + @hasMany('dog', { async: true, inverse: 'owner' }) dogs; + } + + class Dog extends Model { + @attr() name; + @belongsTo('person', { async: true, inverse: 'dogs' }) owner; + } + + hooks.beforeEach(function() { + let { owner } = this; + owner.register('model:person', Person); + owner.register('model:dog', Dog); + owner.register('adapter:application', Adapter.extend()); + owner.register('serializer:application', JSONAPISerializer.extend()); + + store = owner.lookup('service:store'); + adapter = store.adapterFor('application'); + }); + + test('saved changes to relationships should not roll back to a pre-saved state (from child)', async function (assert) { + let person1, person2, dog1, dog2, dog3, dogs, owner; + + adapter.updateRecord = function() { + return resolve({ + data: { type: 'dog', id: 2, relationships: { owner: { data: { type: 'person', id: 1 } } } }, + }); + }; + + store.push({ data: { type: 'person', id: 1, attributes: { firstName: 'Tom', lastName: 'Dale' } } }); + store.push({ data: { type: 'person', id: 2, attributes: { firstName: 'John', lastName: 'Doe' } } }); + store.push({ data: { type: 'dog', id: 1, attributes: { name: 'Fido' }, relationships: { owner: { data: { type: 'person', id: 1 } } } } }); + store.push({ data: { type: 'dog', id: 2, attributes: { name: 'Bear' }, relationships: { owner: { data: { type: 'person', id: 2 } } } } }); + store.push({ data: { type: 'dog', id: 3, attributes: { name: 'Spot' } } }); + person1 = store.peekRecord('person', 1); + person2 = store.peekRecord('person', 2); + dog1 = store.peekRecord('dog', 1); + dog2 = store.peekRecord('dog', 2); + dog3 = store.peekRecord('dog', 3); + dogs = await person1.dogs; + dogs.push(dog2); + + await dog2.save(); + + dogs = await person1.dogs; + dogs.push(dog3); + dog2.rollback(); + dog3.rollback(); + dogs = await person1.dogs; + assert.deepEqual(dogs, [dog1, dog2]); + dogs = await person2.dogs; + assert.deepEqual(dogs, []); + owner = await dog1.owner; + assert.equal(owner, person1); + owner = await dog2.owner; + assert.equal(owner, person1); + }); + + //skip("saved changes to relationships should not roll back to a pre-saved state (from parent)", function (assert) { + // var person1, person2, dog1, dog2, dog3; + // + // adapter.updateRecord = function (store, type, snapshot) { + // return resolve({ id: 1, dogs: [1] }); + // }; + // + // run(function () { + // store.push({ + // data: { + // type: 'person', + // id: 1, + // attributes: { + // firstName: "Tom", + // lastName: "Dale" + // }, + // relationships: { + // dogs: { + // data: [ + // { + // type: 'dog', + // id: 1 + // } + // ] + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'person', + // id: 2, + // attributes: { + // firstName: "John", + // lastName: "Doe" + // }, + // relationships: { + // dogs: { + // data: [ + // { + // type: 'dog', + // id: 2 + // } + // ] + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 1, + // attributes: { + // name: "Fido" + // }, + // relationships: { + // owner: { + // data: { + // type: 'person', + // id: 1 + // } + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 2, + // attributes: { + // name: "Bear" + // }, + // relationships: { + // owner: { + // data: { + // type: 'person', + // id: 2 + // } + // } + // } + // } + // }); + // store.push({ + // data: { + // type: 'dog', + // id: 3, + // attributes: { + // name: "Spot" + // }, + // relationships: { + // owner: { + // data: null + // } + // } + // } + // }); + // person1 = store.peekRecord('person', 1); + // person2 = store.peekRecord('person', 2); + // dog1 = store.peekRecord('dog', 1); + // dog2 = store.peekRecord('dog', 2); + // dog3 = store.peekRecord('dog', 3); + // + // person1.get('dogs').push(dog2); + // }); + // + // run(function () { + // person1.save().then(function () { + // person1.get('dogs').push(dog3); + // return all([person1.rollback()]); + // }).then(function () { + // person1.get('dogs').then(function (dogs) { + // assert.deepEqual(dogs, [dog1, dog2]); + // }); + // person2.get('dogs').then(function (dogs) { + // assert.deepEqual(dogs, []); + // }); + // dog1.get('owner').then(function (owner) { + // assert.equal(owner, person1); + // }).then(function () { + // console.log(person1._internalModel._relationships.get('dogs').manyArray.currentState.map(function (i) { return i.id; })); + // console.log(dog2._internalModel._relationships.get('owner').get('id')); + // console.log(dog3._internalModel._relationships.get('owner').get('id')); + // }); + // dog2.get('owner').then(function (owner) { + // assert.equal(owner, person1); + // }); + // }); + // }); + //}); +});