UNPKG

@ember-data/record-data

Version:

Provides the default resource cache (RecordData) implementation for ember-data

594 lines (534 loc) 20.8 kB
import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; import { LOG_GRAPH } from '@ember-data/private-build-infra/debugging'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; 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 BelongsToRelationship from '../relationships/state/belongs-to'; import ManyRelationship from '../relationships/state/has-many'; import type { EdgeCache, UpgradedMeta } from './-edge-definition'; import { isLHS, upgradeDefinition } from './-edge-definition'; import type { DeleteRecordOperation, LocalRelationshipOperation, RemoteRelationshipOperation, UnknownOperation, } from './-operations'; import { assertValidRelationshipPayload, forAllRelatedIdentifiers, getStore, isBelongsTo, isHasMany, isImplicit, isNew, notifyChange, removeIdentifierCompletelyFromRelationship, } from './-utils'; import addToRelatedRecords from './operations/add-to-related-records'; import { mergeIdentifier } from './operations/merge-identifier'; import removeFromRelatedRecords from './operations/remove-from-related-records'; 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<StableRecordIdentifier>; remoteMembers: Set<StableRecordIdentifier>; } export type RelationshipEdge = ImplicitRelationship | ManyRelationship | BelongsToRelationship; export const Graphs = new Map<RecordDataStoreWrapper, Graph>(); /* * Graph acts as the cache for relationship data. It allows for * us to ask about and update relationships for a given Identifier * without requiring other objects for that Identifier to be * instantiated (such as `RecordData` or a `Record`) * * This also allows for us to make more substantive changes to relationships * with increasingly minor alterations to other portions of the internals * over time. * * The graph is made up of nodes and edges. Each unique identifier gets * its own node, which is a dictionary with a list of that node's edges * (or connections) to other nodes. In `Model` terms, a node represents a * record instance, with each key (an edge) in the dictionary correlating * to either a `hasMany` or `belongsTo` field on that record instance. * * The value for each key, or `edge` is the identifier(s) the node relates * to in the graph from that key. */ export class Graph { declare _definitionCache: EdgeCache; declare _potentialPolymorphicTypes: Dict<Dict<boolean>>; declare identifiers: Map<StableRecordIdentifier, Dict<RelationshipEdge>>; declare store: RecordDataStoreWrapper; declare isDestroyed: boolean; declare _willSyncRemote: boolean; declare _willSyncLocal: boolean; declare _pushedUpdates: { belongsTo: RemoteRelationshipOperation[]; hasMany: RemoteRelationshipOperation[]; deletions: DeleteRecordOperation[]; }; declare _updatedRelationships: Set<ManyRelationship>; declare _transaction: Set<ManyRelationship | BelongsToRelationship> | null; declare _removing: StableRecordIdentifier | null; constructor(store: RecordDataStoreWrapper) { this._definitionCache = Object.create(null) as EdgeCache; this._potentialPolymorphicTypes = Object.create(null) as Dict<Dict<boolean>>; this.identifiers = new Map(); this.store = store; this.isDestroyed = false; this._willSyncRemote = false; this._willSyncLocal = false; this._pushedUpdates = { belongsTo: [], hasMany: [], deletions: [] }; this._updatedRelationships = new Set(); this._transaction = null; this._removing = null; } has(identifier: StableRecordIdentifier, propertyName: string): boolean { let relationships = this.identifiers.get(identifier); if (!relationships) { return false; } return relationships[propertyName] !== undefined; } get(identifier: StableRecordIdentifier, propertyName: string): RelationshipEdge { assert(`expected propertyName`, propertyName); let relationships = this.identifiers.get(identifier); if (!relationships) { relationships = Object.create(null) as Dict<RelationshipEdge>; this.identifiers.set(identifier, relationships); } let relationship = relationships[propertyName]; if (!relationship) { const info = upgradeDefinition(this, identifier, propertyName); assert(`Could not determine relationship information for ${identifier.type}.${propertyName}`, info !== null); const meta = isLHS(info, identifier.type, propertyName) ? info.lhs_definition : info.rhs_definition!; if (meta.kind !== 'implicit') { 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(), }; } } return relationship; } /* * Allows for the graph to dynamically discover polymorphic connections * without needing to walk prototype chains. * * Used by edges when an added `type` does not match the expected `type` * for that edge. * * Currently we assert before calling this. For a public API we will want * to call out to the schema manager to ask if we should consider these * types as equivalent for a given relationship. */ registerPolymorphicType(type1: string, type2: string): void { const typeCache = this._potentialPolymorphicTypes; let t1 = typeCache[type1]; if (!t1) { t1 = typeCache[type1] = Object.create(null) as Dict<boolean>; } t1[type2] = true; let t2 = typeCache[type2]; if (!t2) { t2 = typeCache[type2] = Object.create(null) as Dict<boolean>; } t2[type1] = true; } /* TODO move this comment somewhere else implicit relationships are relationships which have not been declared but the inverse side exists on another record somewhere For example if there was: ```app/models/comment.js import Model, { attr } from '@ember-data/model'; export default class Comment extends Model { @attr text; } ``` and there is also: ```app/models/post.js import Model, { attr, hasMany } from '@ember-data/model'; export default class Post extends Model { @attr title; @hasMany('comment') comments; } ``` Then we would have a implicit 'post' relationship for the comment record in order to be do things like remove the comment from the post if the comment were to be deleted. */ isReleasable(identifier: StableRecordIdentifier): boolean { const relationships = this.identifiers.get(identifier); if (!relationships) { return true; } const keys = Object.keys(relationships); for (let i = 0; i < keys.length; i++) { const relationship = relationships[keys[i]] as RelationshipEdge; assert(`Expected a relationship`, relationship); if (relationship.definition.inverseIsAsync) { return false; } } return true; } unload(identifier: StableRecordIdentifier, silenceNotifications?: boolean) { if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`graph: unload ${String(identifier)}`); } const relationships = this.identifiers.get(identifier); if (relationships) { // cleans up the graph but retains some nodes // to allow for rematerialization Object.keys(relationships).forEach((key) => { let rel = relationships[key]!; if (!rel) { return; } destroyRelationship(this, rel, silenceNotifications); if (isImplicit(rel)) { relationships[key] = undefined; } }); } } remove(identifier: StableRecordIdentifier) { if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`graph: remove ${String(identifier)}`); } assert(`Cannot remove ${String(identifier)} while still removing ${String(this._removing)}`, !this._removing); this._removing = identifier; this.unload(identifier); this.identifiers.delete(identifier); this._removing = null; } /* * Remote state changes */ push(op: RemoteRelationshipOperation) { if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`graph: push ${String(op.record)}`, op); } if (op.op === 'deleteRecord') { this._pushedUpdates.deletions.push(op); } else if (op.op === 'replaceRelatedRecord') { this._pushedUpdates.belongsTo.push(op); } else { const relationship = this.get(op.record, op.field); assert(`Cannot push a remote update for an implicit relationship`, !isImplicit(relationship)); this._pushedUpdates[relationship.definition.kind as 'belongsTo' | 'hasMany'].push(op); } if (!this._willSyncRemote) { this._willSyncRemote = true; getStore(this.store)._schedule('coalesce', () => this._flushRemoteQueue()); } } /* * Local state changes */ update(op: RemoteRelationshipOperation | MergeOperation, isRemote: true): void; update(op: LocalRelationshipOperation, isRemote?: false): void; update( op: MergeOperation | LocalRelationshipOperation | RemoteRelationshipOperation | UnknownOperation, isRemote: boolean = false ): void { assert( `Cannot update an implicit relationship`, op.op === 'deleteRecord' || op.op === 'mergeIdentifiers' || !isImplicit(this.get(op.record, op.field)) ); if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`graph: update (${isRemote ? 'remote' : 'local'}) ${String(op.record)}`, op); } switch (op.op) { case 'mergeIdentifiers': { const relationships = this.identifiers.get(op.record); if (relationships) { mergeIdentifier(this, op, relationships); } break; } case 'updateRelationship': assert(`Can only perform the operation updateRelationship on remote state`, isRemote); if (DEBUG) { // in debug, assert payload validity eagerly // TODO add deprecations/assertion here for duplicates assertValidRelationshipPayload(this, op); } updateRelationshipOperation(this, op); break; case 'deleteRecord': { assert(`Can only perform the operation deleteRelationship on remote state`, isRemote); const identifier = op.record; const relationships = this.identifiers.get(identifier); if (relationships) { Object.keys(relationships).forEach((key) => { const rel = relationships[key]; if (!rel) { return; } // works together with the has check relationships[key] = undefined; removeCompletelyFromInverse(this, rel); }); this.identifiers.delete(identifier); } break; } case 'replaceRelatedRecord': replaceRelatedRecord(this, op, isRemote); break; case 'addToRelatedRecords': addToRelatedRecords(this, op, isRemote); break; case 'removeFromRelatedRecords': removeFromRelatedRecords(this, op, isRemote); break; case 'replaceRelatedRecords': replaceRelatedRecords(this, op, isRemote); break; default: assert(`No local relationship update operation exists for '${op.op}'`); } } _scheduleLocalSync(relationship: ManyRelationship) { this._updatedRelationships.add(relationship); if (!this._willSyncLocal) { this._willSyncLocal = true; getStore(this.store)._schedule('sync', () => this._flushLocalQueue()); } } _flushRemoteQueue() { if (!this._willSyncRemote) { return; } if (LOG_GRAPH) { // eslint-disable-next-line no-console console.groupCollapsed(`Graph: Initialized Transaction`); } this._transaction = new Set(); this._willSyncRemote = false; const { deletions, hasMany, belongsTo } = this._pushedUpdates; this._pushedUpdates.deletions = []; this._pushedUpdates.hasMany = []; this._pushedUpdates.belongsTo = []; for (let i = 0; i < deletions.length; i++) { this.update(deletions[i], true); } for (let i = 0; i < hasMany.length; i++) { this.update(hasMany[i], true); } for (let i = 0; i < belongsTo.length; i++) { this.update(belongsTo[i], true); } this._finalize(); } _addToTransaction(relationship: ManyRelationship | BelongsToRelationship) { assert(`expected a transaction`, this._transaction !== null); if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`Graph: ${String(relationship.identifier)} ${relationship.definition.key} added to transaction`); } relationship.transactionRef++; this._transaction.add(relationship); } _finalize() { if (this._transaction) { this._transaction.forEach((v) => (v.transactionRef = 0)); this._transaction = null; if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`Graph: transaction finalized`); // eslint-disable-next-line no-console console.groupEnd(); } } } _flushLocalQueue() { if (!this._willSyncLocal) { return; } this._willSyncLocal = false; let updated = this._updatedRelationships; this._updatedRelationships = new Set(); updated.forEach((rel) => syncRemoteToLocal(this, rel)); } destroy() { Graphs.delete(this.store); if (DEBUG) { Graphs.delete(getStore(this.store) as unknown as RecordDataStoreWrapper); if (Graphs.size) { Graphs.forEach((_, key) => { assert( `Memory Leak Detected, likely the test or app instance previous to this was not torn down properly`, // @ts-expect-error !key.isDestroyed && !key.isDestroying ); }); } } this.identifiers.clear(); this.store = null as unknown as RecordDataStoreWrapper; this.isDestroyed = true; } } // Handle dematerialization for relationship `rel`. In all cases, notify the // relationship of the dematerialization: this is done so the relationship can // notify its inverse which needs to update state // // If the inverse is sync, unloading this record is treated as a client-side // delete, so we remove the inverse records from this relationship to // disconnect the graph. Because it's not async, we don't need to keep around // the identifier as an id-wrapper for references function destroyRelationship(graph: Graph, rel: RelationshipEdge, silenceNotifications?: boolean) { if (isImplicit(rel)) { if (graph.isReleasable(rel.identifier)) { removeCompletelyFromInverse(graph, rel); } return; } const { identifier } = rel; const { inverseKey } = rel.definition; if (!rel.definition.inverseIsImplicit) { forAllRelatedIdentifiers(rel, (inverseIdentifer: StableRecordIdentifier) => notifyInverseOfDematerialization(graph, inverseIdentifer, inverseKey, identifier, silenceNotifications) ); } if (!rel.definition.inverseIsImplicit && !rel.definition.inverseIsAsync) { rel.state.isStale = true; clearRelationship(rel); // necessary to clear relationships in the ui from dematerialized records // hasMany is managed by Model which calls `retreiveLatest` after // dematerializing the recordData instance. // but sync belongsTo requires this since they don't have a proxy to update. // so we have to notify so it will "update" to null. // we should discuss whether we still care about this, probably fine to just // leave the ui relationship populated since the record is destroyed and // internally we've fully cleaned up. if (!rel.definition.isAsync && !silenceNotifications) { notifyChange(graph, rel.identifier, rel.definition.key); } } } function notifyInverseOfDematerialization( graph: Graph, inverseIdentifier: StableRecordIdentifier, inverseKey: string, identifier: StableRecordIdentifier, silenceNotifications?: boolean ) { if (!graph.has(inverseIdentifier, inverseKey)) { return; } let relationship = graph.get(inverseIdentifier, inverseKey); assert(`expected no implicit`, !isImplicit(relationship)); // For remote members, it is possible that inverseRecordData has already been associated to // to another record. For such cases, do not dematerialize the inverseRecordData if (!isBelongsTo(relationship) || !relationship.localState || identifier === relationship.localState) { removeDematerializedInverse( graph, relationship as BelongsToRelationship | ManyRelationship, identifier, silenceNotifications ); } } function clearRelationship(relationship: ManyRelationship | BelongsToRelationship) { if (isBelongsTo(relationship)) { relationship.localState = null; relationship.remoteState = null; relationship.state.hasReceivedData = false; relationship.state.isEmpty = true; } else { relationship.localMembers.clear(); relationship.remoteMembers.clear(); relationship.localState = []; relationship.remoteState = []; } } function removeDematerializedInverse( graph: Graph, relationship: ManyRelationship | BelongsToRelationship, inverseIdentifier: StableRecordIdentifier, silenceNotifications?: boolean ) { if (isBelongsTo(relationship)) { const inverseIdentifier = relationship.localState; if (!relationship.definition.isAsync || (inverseIdentifier && isNew(inverseIdentifier))) { // unloading inverse of a sync relationship is treated as a client-side // delete, so actually remove the models don't merely invalidate the cp // cache. // if the record being unloaded only exists on the client, we similarly // treat it as a client side delete if (relationship.localState === inverseIdentifier && inverseIdentifier !== null) { relationship.localState = null; } if (relationship.remoteState === inverseIdentifier && inverseIdentifier !== null) { relationship.remoteState = null; relationship.state.hasReceivedData = true; relationship.state.isEmpty = true; if (relationship.localState && !isNew(relationship.localState)) { relationship.localState = null; } } } else { relationship.state.hasDematerializedInverse = true; } if (!silenceNotifications) { notifyChange(graph, relationship.identifier, relationship.definition.key); } } else { if (!relationship.definition.isAsync || (inverseIdentifier && isNew(inverseIdentifier))) { // unloading inverse of a sync relationship is treated as a client-side // delete, so actually remove the models don't merely invalidate the cp // cache. // if the record being unloaded only exists on the client, we similarly // treat it as a client side delete removeIdentifierCompletelyFromRelationship(graph, relationship, inverseIdentifier); } else { relationship.state.hasDematerializedInverse = true; } if (!silenceNotifications) { notifyChange(graph, relationship.identifier, relationship.definition.key); } } } function removeCompletelyFromInverse( graph: Graph, relationship: ImplicitRelationship | ManyRelationship | BelongsToRelationship ) { const { identifier } = relationship; const { inverseKey } = relationship.definition; forAllRelatedIdentifiers(relationship, (inverseIdentifier: StableRecordIdentifier) => { if (graph.has(inverseIdentifier, inverseKey)) { removeIdentifierCompletelyFromRelationship(graph, graph.get(inverseIdentifier, inverseKey), identifier); } }); if (isBelongsTo(relationship)) { if (!relationship.definition.isAsync) { clearRelationship(relationship); } relationship.localState = null; } else if (isHasMany(relationship)) { if (!relationship.definition.isAsync) { clearRelationship(relationship); notifyChange(graph, relationship.identifier, relationship.definition.key); } } else { relationship.remoteMembers.clear(); relationship.localMembers.clear(); } }