@ember-data/record-data
Version:
Provides the default resource cache (RecordData) implementation for ember-data
719 lines (639 loc) • 23.3 kB
text/typescript
/**
* @module @ember-data/record-data
*/
import { assert } from '@ember/debug';
import { schedule } from '@ember/runloop';
import { isEqual } from '@ember/utils';
import { LOG_MUTATIONS, LOG_OPERATIONS } from '@ember-data/private-build-infra/debugging';
import type {
CollectionResourceRelationship,
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 { 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 { graphFor, peekGraph } from './graph/index';
import type BelongsToRelationship from './relationships/state/belongs-to';
import type ManyRelationship from './relationships/state/has-many';
const EMPTY_ITERATOR = {
iterator() {
return {
next() {
return { done: true, value: undefined };
},
};
},
};
/**
The default cache implementation used by ember-data. The cache
is configurable and using a different implementation can be
achieved by implementing the store's createRecordDataFor hook.
@class RecordDataDefault
@public
*/
interface CachedResource {
remoteAttrs: Dict<unknown> | null;
localAttrs: Dict<unknown> | null;
inflightAttrs: Dict<unknown> | null;
changes: Dict<unknown[]> | null;
errors: JsonApiValidationError[] | null;
isNew: boolean;
isDeleted: boolean;
isDeletionCommitted: boolean;
}
function makeCache(): CachedResource {
return {
remoteAttrs: null,
localAttrs: null,
inflightAttrs: null,
changes: null,
errors: null,
isNew: false,
isDeleted: false,
isDeletionCommitted: false,
};
}
export default class SingletonRecordData implements RecordData {
version: '2' = '2';
__storeWrapper: V2RecordDataStoreWrapper;
__cache: Map<StableRecordIdentifier, CachedResource> = new Map();
__destroyedCache: Map<StableRecordIdentifier, CachedResource> = new Map();
constructor(storeWrapper: V2RecordDataStoreWrapper) {
this.__storeWrapper = storeWrapper;
}
/**
* Private method used when the store's `createRecordDataFor` hook is called
* to populate an entry for the identifier into the singleton.
*
* @method createCache
* @private
* @param identifier
*/
createCache(identifier: StableRecordIdentifier): void {
this.__cache.set(identifier, makeCache());
}
__peek(identifier: StableRecordIdentifier, allowDestroyed = false): CachedResource {
let resource = this.__cache.get(identifier);
if (!resource && allowDestroyed) {
resource = this.__destroyedCache.get(identifier);
}
assert(
`Expected RecordData Cache to have a resource cache for the identifier ${String(identifier)} but none was found`,
resource
);
return resource;
}
pushData(
identifier: StableRecordIdentifier,
data: JsonApiResource,
calculateChanges?: boolean | undefined
): void | string[] {
let changedKeys: string[] | undefined;
const cached = this.__peek(identifier);
if (LOG_OPERATIONS) {
try {
let _data = JSON.parse(JSON.stringify(data));
// eslint-disable-next-line no-console
console.log('EmberData | Operation - pushData (upsert)', _data);
} catch (e) {
// eslint-disable-next-line no-console
console.log('EmberData | Operation - pushData (upsert)', data);
}
}
if (cached.isNew) {
cached.isNew = false;
this.__storeWrapper.notifyChange(identifier, 'state');
}
if (calculateChanges) {
changedKeys = calculateChangedKeys(cached, data.attributes);
}
cached.remoteAttrs = Object.assign(cached.remoteAttrs || Object.create(null), data.attributes);
if (cached.localAttrs) {
if (patchLocalAttributes(cached)) {
this.__storeWrapper.notifyChange(identifier, 'state');
}
}
if (data.relationships) {
setupRelationships(this.__storeWrapper, identifier, data);
}
if (changedKeys && changedKeys.length) {
notifyAttributes(this.__storeWrapper, identifier, changedKeys);
}
return changedKeys;
}
sync(op: MergeOperation): void {
if (LOG_OPERATIONS) {
try {
let _data = JSON.parse(JSON.stringify(op));
// eslint-disable-next-line no-console
console.log(`EmberData | Operation - sync ${op.op}`, _data);
} catch (e) {
// eslint-disable-next-line no-console
console.log(`EmberData | Operation - sync ${op.op}`, op);
}
}
if (op.op === 'mergeIdentifiers') {
const cache = this.__cache.get(op.record);
if (cache) {
this.__cache.set(op.value, cache);
this.__cache.delete(op.record);
}
graphFor(this.__storeWrapper).update(op, true);
}
}
update(op: LocalRelationshipOperation): void {
if (LOG_MUTATIONS) {
try {
let _data = JSON.parse(JSON.stringify(op));
// eslint-disable-next-line no-console
console.log(`EmberData | Mutation - update ${op.op}`, _data);
} catch (e) {
// eslint-disable-next-line no-console
console.log(`EmberData | Mutation - update ${op.op}`, op);
}
}
graphFor(this.__storeWrapper).update(op, false);
}
clientDidCreate(identifier: StableRecordIdentifier, options?: Dict<unknown> | undefined): Dict<unknown> {
if (LOG_MUTATIONS) {
try {
let _data = options ? JSON.parse(JSON.stringify(options)) : options;
// eslint-disable-next-line no-console
console.log(`EmberData | Mutation - clientDidCreate ${identifier.lid}`, _data);
} catch (e) {
// eslint-disable-next-line no-console
console.log(`EmberData | Mutation - clientDidCreate ${identifier.lid}`, options);
}
}
const cached = this.__peek(identifier);
cached.isNew = true;
let createOptions = {};
if (options !== undefined) {
const storeWrapper = this.__storeWrapper;
let attributeDefs = storeWrapper.getSchemaDefinitionService().attributesDefinitionFor(identifier);
let relationshipDefs = storeWrapper.getSchemaDefinitionService().relationshipsDefinitionFor(identifier);
const graph = graphFor(storeWrapper);
let propertyNames = Object.keys(options);
for (let i = 0; i < propertyNames.length; i++) {
let name = propertyNames[i];
let propertyValue = options[name];
if (name === 'id') {
continue;
}
const fieldType: AttributeSchema | RelationshipSchema | undefined =
relationshipDefs[name] || attributeDefs[name];
let kind = fieldType !== undefined ? ('kind' in fieldType ? fieldType.kind : 'attribute') : null;
let relationship;
switch (kind) {
case 'attribute':
this.setAttr(identifier, name, propertyValue);
break;
case 'belongsTo':
this.update({
op: 'replaceRelatedRecord',
field: name,
record: identifier,
value: propertyValue as StableRecordIdentifier | null,
});
relationship = graph.get(identifier, name);
relationship.state.hasReceivedData = true;
relationship.state.isEmpty = false;
break;
case 'hasMany':
this.update({
op: 'replaceRelatedRecords',
field: name,
record: identifier,
value: propertyValue as StableRecordIdentifier[],
});
relationship = graph.get(identifier, name);
relationship.state.hasReceivedData = true;
relationship.state.isEmpty = false;
break;
default:
// reflect back (pass-thru) unknown properties
createOptions[name] = propertyValue;
}
}
}
return createOptions;
}
willCommit(identifier: StableRecordIdentifier): void {
const cached = this.__peek(identifier);
cached.inflightAttrs = cached.localAttrs;
cached.localAttrs = null;
}
didCommit(identifier: StableRecordIdentifier, data: JsonApiResource | null): void {
const cached = this.__peek(identifier);
if (cached.isDeleted) {
graphFor(this.__storeWrapper).push({
op: 'deleteRecord',
record: identifier,
isNew: false,
});
cached.isDeletionCommitted = true;
}
cached.isNew = false;
let newCanonicalAttributes: AttributesHash | undefined;
if (data) {
if (data.id) {
// didCommit provided an ID, notify the store of it
this.__storeWrapper.setRecordId(identifier, data.id);
}
if (data.relationships) {
setupRelationships(this.__storeWrapper, identifier, data);
}
newCanonicalAttributes = data.attributes;
}
let changedKeys = calculateChangedKeys(cached, newCanonicalAttributes);
cached.remoteAttrs = Object.assign(
cached.remoteAttrs || Object.create(null),
cached.inflightAttrs,
newCanonicalAttributes
);
cached.inflightAttrs = null;
patchLocalAttributes(cached);
if (cached.errors) {
cached.errors = null;
this.__storeWrapper.notifyChange(identifier, 'errors');
}
notifyAttributes(this.__storeWrapper, identifier, changedKeys);
this.__storeWrapper.notifyChange(identifier, 'state');
}
commitWasRejected(identifier: StableRecordIdentifier, errors?: JsonApiValidationError[] | undefined): void {
const cached = this.__peek(identifier);
if (cached.inflightAttrs) {
let keys = Object.keys(cached.inflightAttrs);
if (keys.length > 0) {
let attrs = (cached.localAttrs = cached.localAttrs || Object.create(null));
for (let i = 0; i < keys.length; i++) {
if (attrs[keys[i]] === undefined) {
attrs[keys[i]] = cached.inflightAttrs[keys[i]];
}
}
}
cached.inflightAttrs = null;
}
if (errors) {
cached.errors = errors;
}
this.__storeWrapper.notifyChange(identifier, 'errors');
}
unloadRecord(identifier: StableRecordIdentifier): void {
const cached = this.__peek(identifier);
const storeWrapper = this.__storeWrapper;
peekGraph(storeWrapper)?.unload(identifier);
// effectively clearing these is ensuring that
// we report as `isEmpty` during teardown.
cached.localAttrs = null;
cached.remoteAttrs = null;
cached.inflightAttrs = null;
let relatedIdentifiers = _allRelatedRecordDatas(storeWrapper, identifier);
if (areAllModelsUnloaded(storeWrapper, relatedIdentifiers)) {
for (let i = 0; i < relatedIdentifiers.length; ++i) {
let identifier = relatedIdentifiers[i];
storeWrapper.disconnectRecord(identifier);
}
}
this.__cache.delete(identifier);
this.__destroyedCache.set(identifier, cached);
/*
* The destroy cache is a hack to prevent applications
* from blowing up during teardown. Accessing state
* on a destroyed record is not safe, but historically
* was possible due to a combination of teardown timing
* and retention of a RecordData instance directly on the
* record itself.
*
* Once we have deprecated accessing state on a destroyed
* instance we may remove this. The timing isn't a huge deal
* as momentarily retaining the objects outside the bounds
* of a test won't cause issues.
*/
if (this.__destroyedCache.size === 1) {
schedule('destroy', () => {
setTimeout(() => {
this.__destroyedCache.clear();
}, 100);
});
}
}
getAttr(identifier: StableRecordIdentifier, attr: string): unknown {
const cached = this.__peek(identifier, true);
if (cached.localAttrs && attr in cached.localAttrs) {
return cached.localAttrs[attr];
} else if (cached.inflightAttrs && attr in cached.inflightAttrs) {
return cached.inflightAttrs[attr];
} else if (cached.remoteAttrs && attr in cached.remoteAttrs) {
return cached.remoteAttrs[attr];
} else {
const attrSchema = this.__storeWrapper.getSchemaDefinitionService().attributesDefinitionFor(identifier)[attr];
return getDefaultValue(attrSchema?.options);
}
}
setAttr(identifier: StableRecordIdentifier, attr: string, value: unknown): void {
const cached = this.__peek(identifier);
const existing =
cached.inflightAttrs && attr in cached.inflightAttrs
? cached.inflightAttrs[attr]
: cached.remoteAttrs && attr in cached.remoteAttrs
? cached.remoteAttrs[attr]
: undefined;
if (existing !== value) {
cached.localAttrs = cached.localAttrs || Object.create(null);
cached.localAttrs![attr] = value;
cached.changes = cached.changes || Object.create(null);
cached.changes![attr] = [existing, value];
} else if (cached.localAttrs) {
delete cached.localAttrs[attr];
delete cached.changes![attr];
}
this.__storeWrapper.notifyChange(identifier, 'attributes', attr);
}
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;
}
rollbackAttrs(identifier: StableRecordIdentifier): string[] {
const cached = this.__peek(identifier);
let dirtyKeys: string[] | undefined;
cached.isDeleted = false;
if (cached.localAttrs !== null) {
dirtyKeys = Object.keys(cached.localAttrs);
cached.localAttrs = null;
cached.changes = null;
}
if (cached.isNew) {
graphFor(this.__storeWrapper).push({
op: 'deleteRecord',
record: identifier,
isNew: true,
});
cached.isDeleted = true;
cached.isNew = false;
}
cached.inflightAttrs = null;
if (cached.errors) {
cached.errors = null;
this.__storeWrapper.notifyChange(identifier, 'errors');
}
this.__storeWrapper.notifyChange(identifier, 'state');
if (dirtyKeys && dirtyKeys.length) {
notifyAttributes(this.__storeWrapper, identifier, dirtyKeys);
}
return dirtyKeys || [];
}
getRelationship(
identifier: StableRecordIdentifier,
field: string
): SingleResourceRelationship | CollectionResourceRelationship {
return (graphFor(this.__storeWrapper).get(identifier, field) as BelongsToRelationship | ManyRelationship).getData();
}
setIsDeleted(identifier: StableRecordIdentifier, isDeleted: boolean): void {
const cached = this.__peek(identifier);
cached.isDeleted = isDeleted;
if (cached.isNew) {
// TODO can we delete this since we will do this in unload?
graphFor(this.__storeWrapper).push({
op: 'deleteRecord',
record: identifier,
isNew: true,
});
}
this.__storeWrapper.notifyChange(identifier, 'state');
}
getErrors(identifier: StableRecordIdentifier): JsonApiValidationError[] {
return this.__peek(identifier, true).errors || [];
}
isEmpty(identifier: StableRecordIdentifier): boolean {
const cached = this.__peek(identifier, true);
return cached.remoteAttrs === null && cached.inflightAttrs === null && cached.localAttrs === null;
}
isNew(identifier: StableRecordIdentifier): boolean {
return this.__peek(identifier, true).isNew;
}
isDeleted(identifier: StableRecordIdentifier): boolean {
return this.__peek(identifier, true).isDeleted;
}
isDeletionCommitted(identifier: StableRecordIdentifier): boolean {
return this.__peek(identifier, true).isDeletionCommitted;
}
}
function areAllModelsUnloaded(wrapper: V2RecordDataStoreWrapper, identifiers: StableRecordIdentifier[]): boolean {
for (let i = 0; i < identifiers.length; ++i) {
let identifier = identifiers[i];
if (wrapper.hasRecord(identifier)) {
return false;
}
}
return true;
}
function getLocalState(rel) {
if (rel.definition.kind === 'belongsTo') {
return rel.localState ? [rel.localState] : [];
}
return rel.localState;
}
function getRemoteState(rel) {
if (rel.definition.kind === 'belongsTo') {
return rel.remoteState ? [rel.remoteState] : [];
}
return rel.remoteState;
}
function getDefaultValue(options: { defaultValue?: unknown } | undefined) {
if (!options) {
return;
}
if (typeof options.defaultValue === 'function') {
// If anyone opens an issue for args not working right, we'll restore + deprecate it via a Proxy
// that lazily instantiates the record. We don't want to provide any args here
// because in a non @ember-data/model world they don't make sense.
return options.defaultValue();
} else {
let defaultValue = options.defaultValue;
assert(
`Non primitive defaultValues are not supported because they are shared between all instances. If you would like to use a complex object as a default value please provide a function that returns the complex object.`,
typeof defaultValue !== 'object' || defaultValue === null
);
return defaultValue;
}
}
function notifyAttributes(storeWrapper: RecordDataStoreWrapper, identifier: StableRecordIdentifier, keys?: string[]) {
if (!keys) {
storeWrapper.notifyChange(identifier, 'attributes');
return;
}
for (let i = 0; i < keys.length; i++) {
storeWrapper.notifyChange(identifier, 'attributes', keys[i]);
}
}
/*
TODO @deprecate IGOR DAVID
There seems to be a potential bug here, where we will return keys that are not
in the schema
*/
function calculateChangedKeys(cached: CachedResource, updates?: AttributesHash) {
let changedKeys: string[] = [];
if (updates) {
const keys = Object.keys(updates);
const length = keys.length;
const localAttrs = cached.localAttrs;
const original = Object.assign(Object.create(null), cached.remoteAttrs, cached.inflightAttrs);
for (let i = 0; i < length; i++) {
let key = keys[i];
let value = updates[key];
// A value in localAttrs means the user has a local change to
// this attribute. We never override this value when merging
// updates from the backend so we should not sent a change
// notification if the server value differs from the original.
if (localAttrs && localAttrs[key] !== undefined) {
continue;
}
if (!isEqual(original[key], value)) {
changedKeys.push(key);
}
}
}
return changedKeys;
}
function setupRelationships(
storeWrapper: RecordDataStoreWrapper,
identifier: StableRecordIdentifier,
data: JsonApiResource
) {
// TODO @runspired iterating by definitions instead of by payload keys
// allows relationship payloads to be ignored silently if no relationship
// definition exists. Ensure there's a test for this and then consider
// moving this to an assertion. This check should possibly live in the graph.
const relationships = storeWrapper.getSchemaDefinitionService().relationshipsDefinitionFor(identifier);
const keys = Object.keys(relationships);
for (let i = 0; i < keys.length; i++) {
const relationshipName = keys[i];
const relationshipData = data.relationships![relationshipName];
if (!relationshipData) {
continue;
}
graphFor(storeWrapper).push({
op: 'updateRelationship',
record: identifier,
field: relationshipName,
value: relationshipData,
});
}
}
function patchLocalAttributes(cached: CachedResource): boolean {
const { localAttrs, remoteAttrs, inflightAttrs, changes } = cached;
if (!localAttrs) {
return false;
}
let hasAppliedPatch = false;
let mutatedKeys = Object.keys(localAttrs);
for (let i = 0, length = mutatedKeys.length; i < length; i++) {
let attr = mutatedKeys[i];
const existing =
inflightAttrs && attr in inflightAttrs
? inflightAttrs[attr]
: remoteAttrs && attr in remoteAttrs
? remoteAttrs[attr]
: undefined;
if (existing === localAttrs[attr]) {
hasAppliedPatch = true;
delete localAttrs[attr];
delete changes![attr];
}
}
return hasAppliedPatch;
}
/*
Iterates over the set of internal models reachable from `this` across exactly one
relationship.
*/
function _directlyRelatedRecordDatasIterable(
storeWrapper: RecordDataStoreWrapper,
originating: StableRecordIdentifier
) {
const graph = peekGraph(storeWrapper);
const initializedRelationships = graph?.identifiers.get(originating);
if (!initializedRelationships) {
return EMPTY_ITERATOR;
}
const initializedRelationshipsArr: Array<ManyRelationship | BelongsToRelationship> = [];
Object.keys(initializedRelationships).forEach((key) => {
const rel = initializedRelationships[key];
if (rel && !isImplicit(rel)) {
initializedRelationshipsArr.push(rel);
}
});
let i = 0;
let j = 0;
let k = 0;
const findNext = () => {
while (i < initializedRelationshipsArr.length) {
while (j < 2) {
let relatedIdentifiers =
j === 0 ? getLocalState(initializedRelationshipsArr[i]) : getRemoteState(initializedRelationshipsArr[i]);
while (k < relatedIdentifiers.length) {
let relatedIdentifier = relatedIdentifiers[k++];
if (relatedIdentifier !== null) {
return relatedIdentifier;
}
}
k = 0;
j++;
}
j = 0;
i++;
}
return undefined;
};
return {
iterator() {
return {
next: () => {
const value = findNext();
return { value, done: value === undefined };
},
};
},
};
}
/*
Computes the set of Identifiers reachable from this Identifier.
Reachability is determined over the relationship graph (ie a graph where
nodes are identifiers and edges are belongs to or has many
relationships).
Returns an array including `this` and all identifiers reachable
from `this.identifier`.
*/
function _allRelatedRecordDatas(
storeWrapper: RecordDataStoreWrapper,
originating: StableRecordIdentifier
): StableRecordIdentifier[] {
let array: StableRecordIdentifier[] = [];
let queue: StableRecordIdentifier[] = [];
let seen = new Set();
queue.push(originating);
while (queue.length > 0) {
let identifier = queue.shift()!;
array.push(identifier);
seen.add(identifier);
const iterator = _directlyRelatedRecordDatasIterable(storeWrapper, originating).iterator();
for (let obj = iterator.next(); !obj.done; obj = iterator.next()) {
const identifier = obj.value;
if (identifier && !seen.has(identifier)) {
seen.add(identifier);
queue.push(identifier);
}
}
}
return array;
}