ember-m3
Version:
Alternative to @ember-data/model in which attributes and relationships are derived from API Payloads
1,250 lines (1,109 loc) • 35.5 kB
JavaScript
import { isEqual, isNone } from '@ember/utils';
import { dasherize } from '@ember/string';
import { assign, merge } from '@ember/polyfills';
import { copy } from './utils/copy';
import { assert } from '@ember/debug';
import Ember from 'ember';
import { recordDataToRecordMap, recordDataToQueryCache } from './utils/caches';
import { CUSTOM_MODEL_CLASS } from 'ember-m3/-infra/features';
const emberAssign = assign || merge;
function pushDataAndNotify(recordData, updates) {
recordData.pushData({ attributes: updates }, true, true);
}
function commitDataAndNotify(recordData, updates) {
recordData.didCommit({ attributes: updates }, true);
}
function notifyProperties(storeWrapper, modelName, id, clientId, changedKeys) {
Ember.beginPropertyChanges();
for (let i = 0; i < changedKeys.length; i++) {
storeWrapper.notifyPropertyChange(modelName, id, clientId, changedKeys[i]);
}
Ember.endPropertyChanges();
}
/**
* A public interface for getting and setting attribute of the underlying
* recordData, and track dependent keys resolved by ref key.
*
* @class M3SchemaInterface
*/
class M3SchemaInterface {
/**
* @param {M3RecordData} recordData
*/
constructor(recordData) {
this.recordData = recordData;
this._keyBeingResolved = null;
this._refKeyDepkeyMap = {};
this._suppressNotifications = false;
}
/**
* @param {string} key
* @private
*/
_beginDependentKeyResolution(key) {
assert(
'Do not invoke `SchemaInterface` method `_beginDependentKeyResolution` without ending the resolution of previous key.',
this._keyBeingResolved === null
);
this._keyBeingResolved = key;
}
/**
* @param {string} key
* @private
*/
_endDependentKeyResolution(key) {
assert(
'Do not invoke `SchemaInterface` method `_endDependentKeyResolution` without begining the resolution of the key.',
key && this._keyBeingResolved === key
);
this._keyBeingResolved = null;
}
_getDependentResolvedKeys(refKey) {
return this._refKeyDepkeyMap[refKey];
}
/**
* Get the attribute name from the model.
* This can be useful if your payload keys are different from your attribute names;
* e.g. if your api adds a prefix to attributes that should be interpreted as references.
*
* @param {string} name name of the attribute
* @returns {Object} value of the attribute
*/
getAttr(name) {
let value = this.recordData.getAttr(name);
const keyBeingResolved = this._keyBeingResolved;
assert(
'Do not manually call methods on `schemaInterface` outside of schema resolution hooks such as `computeAttributeReference`',
keyBeingResolved
);
if (keyBeingResolved !== name) {
// it might be nice to avoid doing this if we know that we don't need to
// support depkeys from the server changing between requests
this._refKeyDepkeyMap[name] = this._refKeyDepkeyMap[name] || [];
let refKeyMap = this._refKeyDepkeyMap[name];
if (refKeyMap.indexOf(keyBeingResolved) < 0) {
refKeyMap.push(this._keyBeingResolved);
}
}
return value;
}
/**
* Set attribute for the recordData
*
* @param {string} key
* @param {Object} value
*/
setAttr(key, value) {
this.recordData.setAttr(key, value, this._suppressNotifications);
}
/**
* Delete attribute for the record data
*
* @param {string} attrName name of the attribute to delete
*/
deleteAttr(attrName) {
this.recordData._deleteAttr(attrName);
}
}
export default class M3RecordData {
/**
* @param {string} modelName
* @param {string} id
* @param {number} [clientId]
* @param {Store} storeWrapper
* @param {SchemaManager} schemaManager
* @param {M3RecordData} [parentRecordData]
* @param {M3RecordData} [baseRecordData]
*/
constructor(
modelName,
id,
clientId,
storeWrapper,
schemaManager,
parentRecordData,
baseRecordData,
globalM3CacheRD
) {
this.modelName = modelName;
this.clientId = clientId;
this.id = id;
this.storeWrapper = storeWrapper;
if (CUSTOM_MODEL_CLASS) {
this.globalM3CacheRD = globalM3CacheRD;
if (!baseRecordData && !parentRecordData && id) {
this.globalM3CacheRD[this.id] = this;
}
this._isNew = false;
this._isDeleted = false;
this._isLoaded = false;
this._isDeletionCommited = false;
} else {
this._embeddedInternalModel = null;
}
this.isDestroyed = false;
this._data = null;
this._attributes = null;
this.__inFlightAttributes = null;
// Properties related to child recordDatas
this._parentRecordData = parentRecordData;
this.__childRecordDatas = null;
this._schema = schemaManager;
this.schemaInterface = new M3SchemaInterface(this);
// Properties related to projections
this._baseRecordData = baseRecordData;
this._projections = null;
this._initBaseRecordData();
}
get _recordArrays() {
if (!this.__recordArrays) {
this.__recordArrays = new Set();
}
return this.__recordArrays;
}
// PUBLIC API
getResourceIdentifier() {
return {
id: this.id,
type: this.modelName,
clientId: this.clientId,
};
}
/**
* Notify this `RecordData` that it has new attributes from the server.
*
* @param {Object} jsonApiResource the payload resource to use for updating
* the server attributes
* @param {boolean} calculateChange Whether or not changes that result from
* this resource being pushed should be calculated.
* @param {boolean} [notifyRecord=false]
* @params {boolean} [suppressProjectionNotifications=false]
* @returns {Array<string>} The list of changed keys if `calculateChange
* === true` and `[]` otherwise.
*/
pushData(
jsonApiResource,
calculateChange,
notifyRecord = false,
suppressProjectionNotifications = false
) {
if (CUSTOM_MODEL_CLASS) {
this._isLoaded = true;
}
if (this._baseRecordData) {
this._baseRecordData.pushData(
jsonApiResource,
calculateChange,
notifyRecord,
suppressProjectionNotifications
);
// we don't need to return any changed keys, because properties will be invalidated
// as part of notifying all projections
return [];
}
let changedKeys;
if (jsonApiResource.attributes) {
changedKeys = this._mergeUpdates(
jsonApiResource.attributes,
pushDataAndNotify,
// if we need to notify the record, we must calculate the changes
calculateChange || notifyRecord || !!this._projections
);
changedKeys = this._filterChangedKeys(changedKeys);
}
if (this.__attributes !== null) {
// only do if we have attribute changes
this._updateChangedAttributes();
}
if (jsonApiResource.id) {
// TODO Which cases do we need to initialize the id here?
this.id = jsonApiResource.id + '';
}
if (CUSTOM_MODEL_CLASS) {
if (!this._baseRecordData && !this._parentRecordData && this.id) {
this.globalM3CacheRD[this.id] = this;
}
}
// by default, always notify projections when we receive data. We might
// not have been asked to calculate changes if the base record data has
// no record, but we might still have records instantiated for our
// projections.
//
// Notifications are explicitly suppressed when we're using `pushData`
// synthetically while resolving nested record data
if (!suppressProjectionNotifications && this._notifyProjectionProperties(changedKeys)) {
return [];
}
if (notifyRecord) {
this._notifyRecordProperties(changedKeys);
}
return changedKeys || [];
}
willCommit() {
if (this._baseRecordData) {
return this._baseRecordData.willCommit();
}
this._inFlightAttributes = this._attributes;
this._attributes = null;
if (this.__childRecordDatas) {
let nestedKeys = Object.keys(this._childRecordDatas);
for (let i = 0; i < nestedKeys.length; ++i) {
let childKey = nestedKeys[i];
let childRecordData = this._childRecordDatas[childKey];
if (!Array.isArray(childRecordData)) {
childRecordData.willCommit();
} else {
childRecordData.forEach(child => child.willCommit());
}
}
}
}
hasChangedAttributes() {
if (this._baseRecordData) {
return this._baseRecordData.hasChangedAttributes();
} else {
let isDirty = this.__attributes !== null && Object.keys(this.__attributes).length > 0;
if (isDirty) {
return true;
}
let recordDatas = Object.keys(this._childRecordDatas).map(key => this._childRecordDatas[key]);
recordDatas.forEach(child => {
if (!Array.isArray(child)) {
if (child.hasChangedAttributes()) {
isDirty = true;
}
} else {
isDirty = isDirty || child.some(rd => rd.hasChangedAttributes());
}
});
return isDirty;
}
}
addToHasMany() {}
removeFromHasMany() {}
_initRecordCreateOptions(options) {
return options !== undefined ? options : {};
}
didCommit(jsonApiResource, notifyRecord = false) {
if (CUSTOM_MODEL_CLASS) {
this._isNew = false;
if (this._isDeleted) {
this._isDeletionCommited = true;
this.removeFromRecordArrays();
}
}
if (jsonApiResource && jsonApiResource.id) {
this.id = '' + jsonApiResource.id;
}
if (CUSTOM_MODEL_CLASS) {
if (!this._baseRecordData && !this._parentRecordData && this.id) {
this.globalM3CacheRD[this.id] = this;
}
}
if (!this._parentRecordData) {
// only set the record ID if it is a top-level recordData
this.storeWrapper.setRecordId(this.modelName, this.id, this.clientId);
}
if (this._baseRecordData) {
this._baseRecordData.didCommit(jsonApiResource, notifyRecord);
// we don't need to return any changed keys, because properties will be invalidated
// as part of notifying all projections
return [];
}
// If the server returns a payload
let attributes;
if (jsonApiResource) {
attributes = jsonApiResource.attributes;
}
// We need to sync nested models in case of partial updates from server and local.
this._syncNestedModelUpdates(attributes);
emberAssign(this._data, this._inFlightAttributes);
this._inFlightAttributes = null;
let changedKeys;
changedKeys = this._mergeUpdates(attributes, commitDataAndNotify, true);
changedKeys = this._filterChangedKeys(changedKeys);
// At this point, all of the nestedModels has been updated, so we can add their updates to the current model's data.
this._mergeNestedModelData();
this._updateChangedAttributes();
if (this._notifyProjectionProperties(changedKeys)) {
return [];
}
if (CUSTOM_MODEL_CLASS) {
this._notifyRecordProperties(changedKeys);
} else {
if (notifyRecord) {
this._notifyRecordProperties(changedKeys);
}
}
return changedKeys || [];
}
getHasMany() {}
setHasMany() {}
commitWasRejected() {
if (this._baseRecordData) {
return this._baseRecordData.commitWasRejected();
}
let keys = Object.keys(this._inFlightAttributes);
if (keys.length > 0) {
let attrs = this._attributes;
for (let i = 0; i < keys.length; i++) {
if (attrs[keys[i]] === undefined) {
attrs[keys[i]] = this._inFlightAttributes[keys[i]];
}
}
}
this._inFlightAttributes = null;
if (this.__childRecordDatas) {
let nestedKeys = Object.keys(this._childRecordDatas);
for (let i = 0; i < nestedKeys.length; ++i) {
let childKey = nestedKeys[i];
let childRecordDatas = this._childRecordDatas[childKey];
if (Array.isArray(childRecordDatas)) {
for (let j = 0; j < childRecordDatas.length; ++j) {
childRecordDatas[j].commitWasRejected();
}
} else {
childRecordDatas.commitWasRejected();
}
}
}
}
getBelongsTo() {}
setBelongsTo() {}
/**
* @param {string} key
* @param {Object} value
* @param {boolean} _suppressNotifications
* @private
*/
setAttr(key, value, _suppressNotifications) {
if (this._baseRecordData) {
return this._baseRecordData.setAttr(key, value, _suppressNotifications);
}
let originalValue;
if (key in this._inFlightAttributes) {
originalValue = this._inFlightAttributes[key];
} else {
originalValue = this._data[key];
}
// If we went back to our original value, we shouldn't keep the attribute around anymore
if (value === originalValue) {
delete this._attributes[key];
} else {
// Add the new value to the changed attributes hash
this._attributes[key] = value;
}
if (!_suppressNotifications && !this._notifyProjectionProperties([key])) {
this._notifyRecordProperties([key]);
}
}
isNew() {
return this._isNew;
}
setIsDeleted(value) {
this._isDeleted = value;
}
isDeleted() {
return this._isDeleted;
}
isDeletionCommitted() {
return this._isDeletionCommited;
}
/**
* @param {string} key
* @private
*/
getAttr(key) {
if (this._baseRecordData) {
return this._baseRecordData.getAttr(key);
} else if (key in this._attributes) {
return this._attributes[key];
} else if (key in this._inFlightAttributes) {
return this._inFlightAttributes[key];
} else {
return this._data[key];
}
}
/**
* @param {string} key
* @private
*/
_deleteAttr(key) {
if (this._baseRecordData) {
return this._baseRecordData._deleteAttr(key);
} else {
delete this._attributes[key];
delete this._data[key];
}
}
/**
* @param {string} key
* @returns {boolean}
*/
hasAttr(key) {
if (this._baseRecordData) {
return this._baseRecordData.hasAttr(key);
} else {
return key in this._attributes || key in this._inFlightAttributes || key in this._data;
}
}
/**
* @param {string} key
* @returns {boolean}
*/
hasLocalAttr(key) {
if (this._baseRecordData) {
return this._baseRecordData.hasLocalAttr(key);
} else {
return key in this._attributes;
}
}
/**
* @param {string} key
* @returns {boolean}
*/
getServerAttr(key) {
if (this._baseRecordData) {
return this._baseRecordData.getServerAttr(key);
} else {
return this._data[key];
}
}
unloadRecord() {
if (CUSTOM_MODEL_CLASS) {
delete this.globalM3CacheRD[this.id];
}
if (this.isDestroyed) {
return;
}
if (CUSTOM_MODEL_CLASS) {
this.removeFromRecordArrays();
let queryCache = recordDataToQueryCache.get(this);
let record = recordDataToRecordMap.get(this);
if (record) {
queryCache.unloadRecord(record);
}
}
if (this._baseRecordData || this._areAllProjectionsDestroyed()) {
this._destroy();
}
}
removeFromRecordArrays() {
this._recordArrays.forEach(recordArray => {
recordArray._removeRecordData(this);
});
}
/**
* @returns {boolean}
*/
isRecordInUse() {
return this.storeWrapper.isRecordInUse(this.modelName, this.id, this.clientId);
}
removeFromInverseRelationships() {}
clientDidCreate() {
if (CUSTOM_MODEL_CLASS) {
this._isLoaded = true;
this._isNew = true;
}
}
// INTERNAL API
/**
* Iterates through the attributes in-flight attrs and data of the model,
* calling the passed function.
*
* @param {Function} callback
* @param {*} binding
*/
eachAttribute(callback, binding) {
if (this._baseRecordData) {
return this._baseRecordData.eachAttribute(callback, binding);
}
let attrs = {};
if (this.__attributes !== null) {
Object.keys(this._attributes).forEach(key => (attrs[key] = true));
}
if (this.__inFlightAttributes !== null) {
Object.keys(this._inFlightAttributes).forEach(key => (attrs[key] = true));
}
if (this.__data !== null) {
this._schema
.computeAttributes(Object.keys(this._data), this.modelName)
.forEach(key => (attrs[key] = true));
}
Object.keys(attrs).forEach(callback, binding);
}
// Exposes attribute keys for the schema service to be able to iterate over the props
// Expected by the ED and snapshot interfaces. Longer term TODO is to look into decoupling
// things more so this is not required
attributesDefinition() {
let attrs = {};
this.eachAttribute(attr => {
attrs[attr] = { key: attr };
});
return attrs;
}
/**
* Returns an object, whose keys are changed properties, and value is an
* [oldProp, newProp] array.
*
* @method changedAttributes
* @returns {Obejct}
* @private
*/
changedAttributes() {
if (this._baseRecordData) {
return this._baseRecordData.changedAttributes();
}
let serverState = this._data;
let localChanges = this._attributes;
let inFlightData = this._inFlightAttributes;
// TODO: test that we copy here
let newData = emberAssign(copy(inFlightData), localChanges);
let _changedAttributes = Object.create(null);
let newDataKeys = Object.keys(newData);
for (let i = 0, length = newDataKeys.length; i < length; i++) {
let key = newDataKeys[i];
_changedAttributes[key] = [serverState[key], newData[key]];
}
if (this.__childRecordDatas) {
let nestedKeys = Object.keys(this._childRecordDatas);
for (let i = 0; i < nestedKeys.length; ++i) {
let childKey = nestedKeys[i];
let childRecordDatas = this._childRecordDatas[childKey];
if (Array.isArray(childRecordDatas)) {
let changes = null;
for (let j = 0; j < childRecordDatas.length; ++j) {
let individualChildRecordData = childRecordDatas[j];
let childChangedAttributes = individualChildRecordData.changedAttributes();
if (Object.keys(childChangedAttributes).length > 0) {
if (changes == null) {
changes = new Array(childRecordDatas.length);
}
changes[j] = childChangedAttributes;
}
}
if (changes !== null) {
_changedAttributes[childKey] = changes;
}
} else {
let childChangedAttributes = childRecordDatas.changedAttributes();
if (Object.keys(childChangedAttributes).length > 0) {
if (
this.getServerAttr(childKey) !== null &&
this.getServerAttr(childKey) !== undefined &&
newData[childKey] === undefined // If object is not already staged for change
) {
_changedAttributes[childKey] = childChangedAttributes;
} else {
_changedAttributes[childKey] = [this.getServerAttr(childKey), childChangedAttributes];
}
}
}
}
}
return _changedAttributes;
}
rollbackAttributes(notifyRecord = false) {
if (this._baseRecordData) {
return this._baseRecordData.rollbackAttributes(...arguments);
}
let dirtyKeys;
if (this.hasChangedAttributes()) {
dirtyKeys = Object.keys(this._attributes);
this._attributes = null;
}
this._inFlightAttributes = null;
if (this.__childRecordDatas) {
let nestedKeys = Object.keys(this._childRecordDatas);
for (let i = 0; i < nestedKeys.length; ++i) {
let childKey = nestedKeys[i];
let childRecordData = this._childRecordDatas[childKey];
if (Array.isArray(childRecordData)) {
for (let j = 0; j < childRecordData.length; ++j) {
childRecordData[j].rollbackAttributes(true);
}
} else {
childRecordData.rollbackAttributes(true);
}
}
}
if (!(dirtyKeys && dirtyKeys.length > 0)) {
// nothing dirty on this record and we've already handled nested records
return;
}
if (this._notifyProjectionProperties(dirtyKeys)) {
// notifyProjectionProperties already invalidated all relevant records' properties
return [];
}
if (notifyRecord) {
this._notifyRecordProperties(dirtyKeys);
}
return dirtyKeys;
}
/**
* @param {string} key
* @returns {boolean}
*/
isAttrDirty(key) {
if (this._baseRecordData) {
return this._baseRecordData.isAttrDirty(...arguments);
}
if (!(key in this._attributes)) {
return false;
}
let originalValue;
if (this._inFlightAttributes[key] !== undefined) {
originalValue = this._inFlightAttributes[key];
} else {
originalValue = this._data[key];
}
return originalValue !== this._attributes[key];
}
/**
* @readonly
* @returns {Object}
*/
get _childRecordDatas() {
if (this.__childRecordDatas === null) {
this.__childRecordDatas = Object.create(null);
}
return this.__childRecordDatas;
}
/**
* @readonly
* @returns {Object}
*/
get _attributes() {
if (this.__attributes === null) {
this.__attributes = Object.create(null);
}
return this.__attributes;
}
set _attributes(v) {
this.__attributes = v;
}
/**
* @readonly
* @returns {Object}
*/
get _data() {
if (this.__data === null) {
this.__data = Object.create(null);
}
return this.__data;
}
set _data(v) {
this.__data = v;
}
get _inFlightAttributes() {
if (this.__inFlightAttributes === null) {
this.__inFlightAttributes = Object.create(null);
}
return this.__inFlightAttributes;
}
set _inFlightAttributes(v) {
this.__inFlightAttributes = v;
}
_initBaseRecordData() {
if (!this._baseRecordData) {
let baseModelName = this._schema.computeBaseModelName(this.modelName);
if (!baseModelName) {
return;
}
this._baseRecordData = this.storeWrapper.recordDataFor(dasherize(baseModelName), this.id);
}
if (this._baseRecordData) {
this._baseRecordData._registerProjection(this);
}
}
/**
* @param {string} key
* @param {string} idx
* @param {string} modelName
* @param {string} id
* @param {_embeddedInternalModel} embeddedInternalModel
* @returns {M3RecordData}
*/
_getChildRecordData(key, idx, modelName, id, embeddedInternalModel) {
let childRecordData;
if (idx !== undefined && idx !== null) {
let childRecordDatas = this._childRecordDatas[key];
if (!childRecordDatas) {
childRecordDatas = this._childRecordDatas[key] = [];
}
childRecordData = childRecordDatas[idx];
if (!childRecordData) {
childRecordData = childRecordDatas[idx] = this._createChildRecordData(
key,
idx,
modelName,
id
);
}
} else {
childRecordData = this._childRecordDatas[key];
if (!childRecordData) {
childRecordData = this._childRecordDatas[key] = this._createChildRecordData(
key,
null,
modelName,
id
);
}
}
if (!CUSTOM_MODEL_CLASS) {
if (!childRecordData._embeddedInternalModel) {
childRecordData._embeddedInternalModel = embeddedInternalModel;
}
}
return childRecordData;
}
/**
* @param {string} key
* @param {string} idx
* @param {string} modelName
* @param {string} id
* @returns {M3RecordData}
*/
_createChildRecordData(key, idx, modelName, id) {
let baseChildRecordData;
if (this._baseRecordData) {
// use the base model name if it is available, but otherwise just use the model name - it might be already
// the base one
let childBaseModelName = this._schema.computeBaseModelName(modelName) || modelName;
baseChildRecordData = this._baseRecordData._getChildRecordData(
key,
idx,
childBaseModelName,
id,
null
);
}
return new M3RecordData(
modelName,
id,
null,
this.storeWrapper,
this._schema,
this,
baseChildRecordData,
this.globalM3CacheRD
);
}
_debugJSON() {
// if the model is a projection, delegate to the base record to get the JSON
if (this._baseRecordData) {
return this._baseRecordData._debugJSON();
}
return this._data;
}
_destroyChildRecordData(key) {
if (this._baseRecordData) {
return this._baseRecordData._destroyChildRecordData(key);
}
if (!this.__childRecordDatas) {
return;
}
return this.__destroyChildRecordData(key);
}
__destroyChildRecordData(key) {
if (!this.__childRecordDatas) {
return;
}
let childRecordData = this._childRecordDatas[key];
if (childRecordData) {
// destroy
delete this._childRecordDatas[key];
}
if (this._projections) {
// TODO Add a test for this destruction
// start from 1 as we know the first projection is the recordData
for (let i = 1; i < this._projections.length; i++) {
this._projections[i].__destroyChildRecordData(key);
}
}
}
/**
* Returns an existing child recordData, which can be reused for merging updates or undefined if
* there is no such child recordData.
*
* @param {string} key - The key, which to apply an update to
* @param {Mixed} newValue - The updates, which needs to be merged
* @return {M3RecordData} The child record data, which can be reused or undefined if there is none.
*/
_getExistingChildRecordData(key, newValue) {
if (
!this.__childRecordDatas ||
!this.__childRecordDatas[key] ||
Array.isArray(this.__childRecordDatas[key])
) {
return undefined;
}
let nested = this._childRecordDatas[key];
// we need to compute the new nested type, hopefully it is not too slow
let newNestedDef = this._schema.computeNestedModel(
key,
newValue,
this.modelName,
this.schemaInterface
);
let newType = newNestedDef && newNestedDef.type && dasherize(newNestedDef.type);
let isSameType = newType === nested.modelName || (isNone(newType) && isNone(nested.modelName));
let newId = newNestedDef && newNestedDef.id;
let isSameId = newId === nested.id || (isNone(newId) && isNone(nested.id));
return newNestedDef && isSameType && isSameId ? nested : null;
}
/**
* Updates the childRecordDatas for a key, which is an array,
* upon any updates to resolved tracked array.
* @param {string} key
* @param {string} idx
* @param {string} removeLength
* @param {string} addLength
*/
_resizeChildRecordData(key, idx, removeLength, addLength) {
if (this._baseRecordData) {
this._baseRecordData._resizeChildRecordData(key, idx, removeLength, addLength);
}
const childRecordDatas = this._childRecordDatas && this._childRecordDatas[key];
if (!childRecordDatas) {
return;
}
assert(
`Cannot invoke '_resizeChildRecordData' as childRecordData for ${key} is not an array`,
Array.isArray(childRecordDatas)
);
const newItemsInChildRecordData = new Array(addLength);
Array.prototype.splice.apply(
childRecordDatas,
[idx, removeLength].concat(newItemsInChildRecordData)
);
}
_setChildRecordData(key, idx, recordData) {
if (recordData._baseRecordData && this._baseRecordData) {
this._baseRecordData._setChildRecordData(key, idx, recordData._baseRecordData);
} else if (!recordData._baseRecordData && !this._baseRecordData) {
// TODO assert against one of these being set but the other one not
if (idx !== undefined && idx !== null) {
let childRecordDatas = this._childRecordDatas[key];
if (childRecordDatas === undefined) {
childRecordDatas = this._childRecordDatas[key] = [];
}
childRecordDatas[idx] = recordData;
} else {
this._childRecordDatas[key] = recordData;
}
} else {
assert(
'Projection levels match between the nested recordData being set and the parent recordData',
false
);
}
}
_registerProjection(recordData) {
if (!this._projections) {
// we ensure projections contains the base as well
// so we have complete list of all related recordDatas
this._projections = [this];
}
this._projections.push(recordData);
}
_unregisterProjection(recordData) {
if (!this._projections) {
return;
}
let idx = this._projections.indexOf(recordData);
if (idx === -1) {
return;
}
this._projections.splice(idx, 1);
// if all projetions have been destroyed and the record is not use, destroy as well
if (this._areAllProjectionsDestroyed() && !this.isRecordInUse()) {
this._destroy();
}
}
_destroy() {
this.isDestroyed = true;
this.storeWrapper.disconnectRecord(this.modelName, this.id, this.clientId);
if (this._baseRecordData) {
this._baseRecordData._unregisterProjection(this);
}
}
/**
* Checks if the attributes which are considered as changed are still
* different to the state which is acknowledged by the server.
*
* This method is needed when data for the internal model is pushed and the
* pushed data might acknowledge dirty attributes as confirmed.
*
* @method _updateChangedAttributes
* @private
*/
_updateChangedAttributes() {
let changedAttributes = this.changedAttributes();
let changedAttributeNames = Object.keys(changedAttributes);
let attrs = this._attributes;
for (let i = 0, length = changedAttributeNames.length; i < length; i++) {
let attribute = changedAttributeNames[i];
let data = changedAttributes[attribute];
let oldData = data[0];
let newData = data[1];
if (oldData === newData) {
delete attrs[attribute];
}
}
}
/**
* Filters keys, which have local changes in _attributes, because even their value on
* the server has changed, their local value is not and no property notification should
* be sent for them.
*
* @method _filterChangedKeys
* @param {Array<string>} changedKeys
* @returns {Array<string>}
* @private
*/
_filterChangedKeys(changedKeys) {
if (!changedKeys || changedKeys.length === 0) {
return changedKeys;
}
if (!this.hasChangedAttributes()) {
return changedKeys;
}
let attrs = this._attributes;
return changedKeys.filter(key => attrs[key] === undefined);
}
_areAllProjectionsDestroyed() {
if (!this._projections) {
// no projections were ever registered
return true;
}
// if this recordData is the last one in the projections list, then all of the others have been destroyed
// note: should not be possible to get into state of no projections (projections.length === 0)
return this._projections.length === 1 && this._projections[0] === this;
}
/**
* Merges updates from the server and delegates changes in nested objects to their respective
* child recordData.
*
* @param {Object} updates
* @param {Function} nestedCallback a callback for updating the data of a nested RecordData instance
* @param {boolean} calculateChanges
* @returns {Array<string>} The list of changed keys ignoring any changes in its children.
* @private
*/
_mergeUpdates(updates, nestedCallback, calculateChanges) {
let data = this._data;
let changedKeys;
if (calculateChanges) {
changedKeys = [];
}
if (!updates) {
return changedKeys;
}
let updatedKeys = Object.keys(updates);
for (let i = 0; i < updatedKeys.length; i++) {
let key = updatedKeys[i];
let newValue = updates[key];
if (isEqual(data[key], newValue)) {
// values are equal, nothing to do
// note, updates to objects should always result in new object or there will be nothing to update
continue;
}
let reusableChild = this._getExistingChildRecordData(key, newValue);
if (reusableChild) {
nestedCallback(reusableChild, newValue);
continue;
}
// not an embedded object, destroy the nested recordData
this._destroyChildRecordData(key);
if (calculateChanges) {
changedKeys.push(key);
}
data[key] = newValue;
}
return changedKeys;
}
_notifyRecordProperties(changedKeys) {
if (CUSTOM_MODEL_CLASS) {
let record = recordDataToRecordMap.get(this);
if (record) {
record._notifyProperties(changedKeys);
}
} else {
if (this._embeddedInternalModel) {
this._embeddedInternalModel.record._notifyProperties(changedKeys);
} else if (!this._parentRecordData) {
notifyProperties(this.storeWrapper, this.modelName, this.id, this.clientId, changedKeys);
}
}
// else base recordData that was initialized by a projection but never
// fetched via `unknownProperty`, which is the only case where we have no
// record, and therefore nothing to notify
}
_notifyProjectionProperties(changedKeys) {
if (!changedKeys || !changedKeys.length) {
return false;
}
let projections = this._projections;
if (!projections) {
return false;
}
for (let i = 0; i < projections.length; i++) {
projections[i]._notifyRecordProperties(changedKeys);
}
return true;
}
/**
* If there are local changes that are not altered by the server payload, we need to manually call didCOmmit
* on them to sync their states.
*/
_syncNestedModelUpdates(attributes) {
// Iterate through the children and call didCommit on it to ensure the childRecordData has the correct state.
const childRecordDatas = this._getChildRecordDatas();
childRecordDatas.forEach(childRecordData => {
// Don't do anything if the key is inside the server payload
if (attributes && childRecordData.key in attributes) {
return;
}
if (!Array.isArray(childRecordData.data)) {
childRecordData.data.didCommit();
} else {
childRecordData.data.forEach(child => child.didCommit());
}
});
}
/**
* Merge data from nested models into parent, so its data is correctly in sync with its children.
*/
_mergeNestedModelData() {
// We need to recursively copy the childRecordDatas into data, to ensure the top level model knows about the change.
const childRecordDatas = this._getChildRecordDatas();
childRecordDatas.forEach(childRecordData => {
if (!Array.isArray(childRecordData.data)) {
this._data[childRecordData.key] = childRecordData.data._data;
} else {
this._data[childRecordData.key] = childRecordData.data.map(child => child._data);
}
});
}
/**
* Helper function for returning childRecordDatas in {key, value} format
* e.g, [{childKey, childRecordData}, {...}]
*/
_getChildRecordDatas() {
if (this.__childRecordDatas) {
let nestedKeys = Object.keys(this._childRecordDatas);
return nestedKeys.map(nestedKey => {
return {
key: nestedKey,
data: this._childRecordDatas[nestedKey],
};
});
}
return [];
}
toString() {
return `<${this.modelName}:${this.id}>`;
}
}