ember-data-change-tracker
Version:
Track changes and rollback object attributes and relationships. Ember data 2.5+
571 lines (515 loc) • 17 kB
JavaScript
import Ember from 'ember';
import { didModelChange, didModelsChange, relationShipTransform, relationshipKnownState } from './utilities';
const assign = Ember.assign || Ember.merge;
export const ModelTrackerKey = '-change-tracker';
export const RelationshipsKnownTrackerKey = '-change-tracker-relationships-known';
const alreadyTrackedRegex = /^-mf-|string|boolean|date|^number$/,
knownTrackerOpts = Ember.A(['only', 'auto', 'except', 'trackHasMany', 'enableIsDirty']),
defaultOpts = {trackHasMany: true, auto: false, enableIsDirty: false};
/**
* Helper class for change tracking models
*/
export default class Tracker {
/**
* Get Ember application container
*
* @param {DS.Model} model
* @returns {*}
*/
static container(model) {
return Ember.getOwner ? Ember.getOwner(model.store) : model.store.container;
}
/**
* Get tracker configuration from Ember application configuration
*
* @param {DS.Model} model
* @returns {*|{}}
*/
static envConfig(model) {
let config = this.container(model).resolveRegistration('config:environment');
// sometimes the config is not available ?? not sure why
return config && config.changeTracker || {};
}
/**
* Get tracker configuration that is set on the model
*
* @param {DS.Model} model
* @returns {*|{}}
*/
static modelConfig(model) {
return model.changeTracker || {};
}
/**
* Is this model in auto save mode?
*
* @param model
* @returns {Boolean}
*/
static isAutoSaveEnabled(model) {
if (model.constructor.trackerAutoSave === undefined) {
let options = this.options(model);
model.constructor.trackerAutoSave = options.auto;
}
return model.constructor.trackerAutoSave;
}
/**
* Is this model have isDirty option enabled?
*
* @param model
* @returns {Boolean}
*/
static isIsDirtyEnabled(model) {
if (model.constructor.trackerEnableIsDirty === undefined) {
let options = this.options(model);
model.constructor.trackerEnableIsDirty = options.enableIsDirty;
}
return model.constructor.trackerEnableIsDirty;
}
/**
* A custom attribute should have a transform function associated with it.
* If not, use object transform.
*
* A transform function is required for serializing and deserializing
* the attribute in order to save past values and then renew them on rollback
*
* @param {DS.Model} model
* @param {String} attributeType like: 'object', 'json' or could be undefined
* @returns {*}
*/
static transformFn(model, attributeType) {
let transformType = attributeType || 'object';
return this.container(model).lookup(`transform:${transformType}`);
}
/**
* The rollback data will be an object with keys as attribute and relationship names
* with values for those keys.
*
* For example:
*
* { id: 1, name: 'Acme Inc', company: 1, pets: [1,2] }
*
* Basically a REST style payload. So, convert that to JSONAPI so it can be
* pushed to the store
*
* @param {DS.Model} model
* @param {Object} data rollback data
*/
static normalize(model, data) {
let container = this.container(model);
let serializer = container.lookup('serializer:-rest');
serializer.set('store', model.store);
return serializer.normalize(model.constructor, data);
}
/**
* Find the meta data for all keys or a single key (attributes/association)
* that tracker is tracking on this model
*
* @param {DS.Model} model
* @param {string} [key] only this key's info and no other
* @returns {*} all the meta info on this model that tracker is tracking
*/
static metaInfo(model, key = null) {
let info = (model.constructor.trackerKeys || {});
if (key) {
return info[key];
}
return info;
}
/**
* Find whether this key is currently being tracked.
*
* @param {DS.Model} model
* @param {string} [key]
* @returns {boolean} true if this key is being tracked. false otherwise
*/
static isTracking(model, key) {
let info = (model.constructor.trackerKeys || {});
return !!info[key];
}
/**
* On the model you can set options like:
*
* changeTracker: {auto: true}
* changeTracker: {auto: true, enableIsDirty: true}
* changeTracker: {auto: true, only: ['info']}
* changeTracker: {except: ['info']}
* changeTracker: {except: ['info'], trackHasMany: true}
*
* In config environment you can set options like:
*
* changeTracker: {auto: true, trackHasMany: false, enableIsDirty: true}
* // default is: {auto: false, trackHasMany: true, enableIsDirty: false}
*
* The default is set to trackHasMany but not auto track, since
* that is the most do nothing approach and when you do call `model.startTrack()`
* it is assumed you want to track everything.
*
* Also, by default the isDirty computed property is not setup. You have to enable
* it globally or on a model
*
* @param {DS.Model} model
* @returns {*}
*/
static options(model) {
let envConfig = this.envConfig(model);
let modelConfig = this.modelConfig(model);
let opts = assign({}, defaultOpts, envConfig, modelConfig);
let unknownOpts = Object.keys(opts).filter((v) => !knownTrackerOpts.includes(v));
Ember.assert(`[ember-data-change-tracker] changeTracker options can have
'only', 'except' , 'auto', 'enableIsDirty' or 'trackHasMany' but you are declaring: ${unknownOpts}`,
Ember.isEmpty(unknownOpts)
);
return opts;
}
// has tracking already been setup on this model?
static trackingIsSetup(model) {
return model.constructor.alreadySetupTrackingMeta;
}
/**
* Setup tracking meta data for this model,
* unless it's already been setup
*
* @param {DS.Model} model
*/
static setupTracking(model) {
if (!this.trackingIsSetup(model)) {
model.constructor.alreadySetupTrackingMeta = true;
let info = Tracker.getTrackerInfo(model);
model.constructor.trackerKeys = info.keyMeta;
model.constructor.trackerAutoSave = info.autoSave;
model.constructor.trackerEnableIsDirty = info.enableIsDirty;
}
}
/**
* Get the tracker meta data associated with this model
*
* @param {DS.Model} model
* @returns {{autoSave, keyMeta: {}}}
*/
static getTrackerInfo(model) {
let [trackableInfo, hasManyList] = this.extractKeys(model);
let trackerOpts = this.options(model);
let all = Object.keys(trackableInfo);
let except = trackerOpts.except || [];
let only = trackerOpts.only || [...all];
if (!trackerOpts.trackHasMany) {
except = [...except, ...hasManyList];
}
all = [...all].filter(a => !except.includes(a));
all = [...all].filter(a => only.includes(a));
let keyMeta = {};
Object.keys(trackableInfo).forEach(key => {
if (all.includes(key)) {
let info = trackableInfo[key];
info.transform = this.getTransform(model, key, info);
keyMeta[key] = info;
}
});
let {enableIsDirty} = trackerOpts;
return {autoSave: trackerOpts.auto, enableIsDirty, keyMeta};
}
/**
* Go through the models attributes and relationships so see
* which of these keys could be trackable
*
* @param {DS.Model} model
* @returns {[*,*]} meta data about possible keys to track
*/
static extractKeys(model) {
let {constructor} = model;
let trackerKeys = {};
let hasManyList = [];
constructor.eachAttribute((attribute, meta) => {
if (!alreadyTrackedRegex.test(meta.type)) {
trackerKeys[attribute] = {type: 'attribute', name: meta.type};
}
});
constructor.eachRelationship((key, relationship) => {
trackerKeys[key] = {
type: relationship.kind,
polymorphic: relationship.options.polymorphic,
knownState: relationshipKnownState[relationship.kind]
};
if (relationship.kind === 'hasMany') {
hasManyList.push(key);
}
});
return [trackerKeys, hasManyList];
}
/**
* Get the transform for an attribute or association.
* The attribute transforms are held by ember-data, and
* the tracker uses custom transform for relationships
*
* @param {DS.Model} model
* @param {String} key attribute/association name
* @param {Object} info tracker meta data for this key
* @returns {*}
*/
static getTransform(model, key, info) {
let transform;
if (info.type === 'attribute') {
transform = this.transformFn(model, info.name);
Ember.assert(`[ember-data-change-tracker] changeTracker could not find
a ${info.name} transform function for the attribute '${key}' in
model '${model.constructor.modelName}'.
If you are in a unit test, be sure to include it in the list of needs`,
transform
);
} else {
transform = relationShipTransform[info.type];
}
return transform;
}
/**
* Did the key change since the last time state was saved?
*
* @param {DS.Model} model
* @param {String} key attribute/association name
* @param {Object} [changed] changed object
* @param {Object} [info] model tracker meta data object
* @returns {*}
*/
static didChange(model, key, changed, info) {
changed = changed || model.changedAttributes();
if (changed[key]) {
return true;
}
let keyInfo = info && info[key] || this.metaInfo(model, key);
if (keyInfo) {
let current = this.serialize(model, key, keyInfo);
let last = this.lastValue(model, key);
switch (keyInfo.type) {
case 'attribute':
case 'belongsTo':
return didModelChange(current, last, keyInfo.polymorphic);
case 'hasMany':
return didModelsChange(current, last, keyInfo.polymorphic);
}
}
}
/**
* Serialize the value to be able to tell if the value changed.
*
* For attributes, using the transform function that each custom
* attribute should have.
*
* For belongsTo, and hasMany using using custom transform
*
* @param {DS.Model} model
* @param {String} key attribute/association name
*/
static serialize(model, key, keyInfo) {
let info = keyInfo || this.metaInfo(model, key);
let value;
if (info.type === 'attribute') {
value = info.transform.serialize(model.get(key));
if (typeof value !== 'string') {
value = JSON.stringify(value);
}
} else {
value = info.transform.serialize(model, key, info);
}
return value;
}
/**
* Determine if the key represents data that the client knows about.
*
* For relationships that are async links it may be that they are yet to be
* loaded and so a determination of 'changed' cannot be known
*
* @param {DS.Model} model
* @param {String} key attribute/association name
*/
static isKnown(model, key, keyInfo) {
let info = keyInfo || this.metaInfo(model, key);
let value;
if (info.type === 'attribute') {
value = true;
} else {
value = info.knownState.isKnown(model, key);
}
return value;
}
/**
* Retrieve the last known value for this model key
*
* @param {DS.Model} model
* @param {String} key attribute/association name
* @returns {*}
*/
static lastValue(model, key) {
return (model.get(ModelTrackerKey) || {})[key];
}
/**
* Retrieve the last known state for this model key
*
* @param {DS.Model} model
* @param {String} key attribute/association name
* @returns {*}
*/
static lastKnown(model, key) {
return (model.get(RelationshipsKnownTrackerKey) || {})[key];
}
/**
* Gather all the rollback data
*
* @param {DS.Model} model
* @param trackerInfo
* @returns {{*}}
*/
static rollbackData(model, trackerInfo) {
let data = {id: model.id};
Object.keys(trackerInfo).forEach((key) => {
let keyInfo = trackerInfo[key];
if (this.didChange(model, key, null, trackerInfo)) {
// For now, blow away the hasMany relationship before resetting it
// since just pushing new data is not resetting the relationship.
// This slows down the hasMany rollback by about 25%, but still
// fast => (~100ms) with 500 items in a hasMany
if (keyInfo.type === 'hasMany') {
model.set(key, []);
}
let lastValue = Tracker.lastValue(model, key);
if (keyInfo.type === 'attribute' && !keyInfo.name) { // attr() undefined type
lastValue = keyInfo.transform.deserialize(lastValue);
}
data[key] = lastValue;
}
});
return data;
}
/**
* Save change tracker attributes
*
* @param {DS.Model} model
* @param {Object} options
* except array of keys to exclude
*/
static saveChanges(model, {except = []} = {}) {
let metaInfo = this.metaInfo(model);
let keys = Object.keys(metaInfo).filter(key => !except.includes(key));
Tracker.saveKeys(model, keys);
}
/**
* Save the current relationship value into the hash only if it was previously
* unknown (i.e. to be loaded async via a link)
*
* @param {DS.Model} model
* @param {String} key association name
* @returns {boolean} true if the current relationship value was saved, false otherwise
*/
static saveLoadedRelationship(model, key) {
let saved = false;
if (!Tracker.lastKnown(model, key)) {
let keyInfo = this.metaInfo(model, key);
if (Tracker.isKnown(model, key, keyInfo)) {
Tracker.saveKey(model, key);
saved = true;
}
}
return saved;
}
/**
* Manually trigger the isDirty properties to refresh themselves
*
* @param {DS.Model} model
*/
static triggerIsDirtyReset(model) {
model.notifyPropertyChange('hasDirtyAttributes');
model.notifyPropertyChange('hasDirtyRelations');
}
/**
* Save the value from an array of keys model's tracker hash
* and save the relationship states if keys represents a relationship
*
* @param {DS.Model} model
* @param {Array} keys to save
*/
static saveKeys(model, keys){
let modelTracker = model.get(ModelTrackerKey) || {},
relationshipsKnownTracker = model.get(RelationshipsKnownTrackerKey) || {},
isNew = model.get('isNew');
keys.forEach(key => {
modelTracker[key] = isNew ? undefined : this.serialize(model, key);
relationshipsKnownTracker[key] = isNew ? true : this.isKnown(model, key);
})
model.setProperties({[ModelTrackerKey]:modelTracker, [RelationshipsKnownTrackerKey]: relationshipsKnownTracker})
}
/**
* Save current model key value in model's tracker hash
* and save the relationship state if key represents a relationship
*
* @param {DS.Model} model
* @param {String} key attribute/association name
*/
static saveKey(model, key) {
this.saveKeys(model, [key]);
}
/**
* Remove tracker hashes from the model's state
*
* @param {DS.Model} model
*/
static clear(model) {
model.set(ModelTrackerKey, undefined);
model.set(RelationshipsKnownTrackerKey, undefined);
}
/**
* Set up the computed properties:
*
* 'isDirty', 'hasDirtyAttributes', 'hasDirtyRelations'
*
* only if the application or model configuration has opted into
* enable these properties, with the enableIsDirty flag
*
* @param {DS.Model} model
*/
static initializeDirtiness(model) {
const relations = [];
const relationsObserver = [];
const attrs = [];
model.eachRelationship((name, descriptor) => {
if (descriptor.kind === 'hasMany') {
relations.push(descriptor.key);
if (descriptor.options.async) {
relationsObserver.push(descriptor.key + '.content.@each.id');
} else {
relationsObserver.push(descriptor.key + '.@each.id');
}
} else {
relations.push(descriptor.key);
relationsObserver.push(descriptor.key + '.content');
}
});
model.eachAttribute(name => {
return attrs.push(name);
});
const hasDirtyRelations = function() {
const changed = model.modelChanges();
return !!relations.find(key => changed[key]);
};
const hasDirtyAttributes = function() {
const changed = model.modelChanges();
return !!attrs.find(key => changed[key]);
};
const isDirty = function() {
return model.get('hasDirtyAttributes') || model.get('hasDirtyRelations');
};
Ember.defineProperty(
model,
'hasDirtyAttributes',
Ember.computed.apply(Ember, attrs.concat([hasDirtyAttributes]))
);
Ember.defineProperty(
model,
'hasDirtyRelations',
Ember.computed.apply(Ember, relationsObserver.concat([hasDirtyRelations]))
);
Ember.defineProperty(
model,
'isDirty',
Ember.computed.apply(Ember, ['hasDirtyAttributes', 'hasDirtyRelations', isDirty])
);
}
}