Skip to content

Instantly share code, notes, and snippets.

@andrewmp1
Last active July 2, 2016 17:36
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andrewmp1/5672845 to your computer and use it in GitHub Desktop.
Save andrewmp1/5672845 to your computer and use it in GitHub Desktop.
Ember-data has existed for quite a while and seems to actually have a very good api for interfacing w/ a model. Maybe we should include it in ember.js as a public interface. See introduction.md for more thoughts.
// Ember.Model has slowly developed a stable API. Lets make it an interface.
Ember.Model = Ember.Object.extend(Ember.Evented, {
/**
Reference the original json that created the record.
@method data
@returns {Object} of data used to create the record
*/
data: Ember.K,
/**
Set this property to false when the record is being fetched/updated from a server
*/
isLoaded: true,
/**
Set this property to true when the record is being fetched/updated from a server
*/
isLoading: false,
isSaving: false,
isDeleted: false,
isNew: true,
isReloading: false,
isDeleted: false,
isError: false,
isValid: true,
save: Ember.K
reload: Ember.K
/**
Create a JSON representation of the record.
@method serialize
@param {Object} options Available options:
@returns {Object} an object whose values are primitive JSON values only
*/
toJSON: Ember.K
/**
@method toJSON
@returns {Object} A JSON representation of the object.
*/
serialize: Ember.K
});
Ember.Model.reopenClass({
createRecord: Ember.K
find: function(){
if (!arguments.length) {
return this.findAll();
} else if (Ember.isArray(id)) {
return this.findMany(id);
} else if (typeof id === 'object') {
return this.findQuery(id);
} else {
return this.findById(id);
}
},
findAll: Ember.K,
findById: Ember.K,
findQuery: Ember.K,
load: Ember.K
});
// https://github.com/balanced/balanced-dashboard/commit/a1c0848660bb712113f18e968f34b305d27e24ee
Balanced.Model = Ember.Object.extend(Ember.Evented, Ember.Copyable, {
isLoaded: false,
isSaving: false,
isDeleted: false,
isError: false,
isNew: true,
isValid: true,
date_formats: {
short: '%e %b \'%y %l:%M %p'
},
human_readable_created_at: function () {
if (this.get('created_at')) {
return Date.parseISO8601(this.get('created_at')).strftime(this.date_formats.short);
} else {
return '';
}
}.property('created_at'),
create: function () {
var self = this;
var data = this._toSerializedJSON();
self.set('isSaving', true);
Balanced.Adapter.create(this.constructor, this.get('uri'), data, function (json) {
self._updateFromJson(json);
self.set('isNew', false);
self.set('isSaving', false);
self.set('isValid', true);
self.set('isError', false);
self.trigger('didCreate');
}, $.proxy(self._handleError, self));
},
update: function () {
var self = this;
var data = this._toSerializedJSON();
self.set('isSaving', true);
Balanced.Adapter.update(this.constructor, this.get('uri'), data, function (json) {
self._updateFromJson(json);
self.set('isSaving', false);
self.set('isValid', true);
self.set('isError', false);
self.trigger('didUpdate');
}, $.proxy(self._handleError, self));
},
delete: function () {
var self = this;
self.set('isDeleted', true);
self.set('isSaving', true);
Balanced.Adapter.delete(this.constructor, this.get('uri'), function (json) {
self.set('isSaving', false);
self.trigger('didDelete');
}, $.proxy(self._handleError, self));
},
refresh: function () {
var self = this;
this.set('isLoaded', false);
Balanced.Adapter.get(this.constructor, this.get('uri'), function (json) {
self._updateFromJson(json);
self.set('isLoaded', true);
self.trigger('didLoad');
});
},
copy: function () {
var modelObject = this.constructor.create({uri: this.get('uri')});
var props = this._toSerializedJSON();
modelObject._updateFromJson(props);
return modelObject;
},
updateFromModel: function (modelObj) {
var modelProps = modelObj._propertiesMap();
this.setProperties(modelProps);
},
_updateFromJson: function (json) {
if (!json) {
return;
}
if (this.constructor.deserialize) {
this.constructor.deserialize(json);
}
this.setProperties(json);
this.set('isLoaded', true);
},
_toSerializedJSON: function() {
var json = this._propertiesMap();
if(this.constructor.serialize) {
this.constructor.serialize(json);
}
return json;
},
_handleError: function (jqXHR, textStatus, errorThrown) {
this.set('isSaving', false);
if (jqXHR.status === 400) {
this.set('isValid', false);
this.trigger('becameInvalid', jqXHR.responseText);
} else {
this.set('isError', true);
this.trigger('becameError', jqXHR.responseText);
}
},
// Taken from http://stackoverflow.com/questions/9211844/reflection-on-emberjs-objects-how-to-find-a-list-of-property-keys-without-knowi
_propertiesMap: function () {
var computedProps = [];
this.constructor.eachComputedProperty(function(prop) {
computedProps.push(prop);
});
var lifecycleProperties = ['isLoaded', 'isNew', 'isSaving', 'isValid', 'isError', 'isDeleted'];
var props = {};
for (var prop in this) {
if (this.hasOwnProperty(prop) &&
$.inArray(prop, computedProps) === -1 &&
$.inArray(prop, lifecycleProperties) === -1 &&
prop.indexOf('__ember') < 0 &&
prop.indexOf('_super') < 0 &&
Ember.typeOf(this.get(prop)) !== 'function' &&
prop !== 'uri' &&
prop !== 'id') {
props[prop] = this[prop];
}
}
return props;
}
});
Balanced.Model.reopenClass({
/* deserialize - override this with a function to transform the json before it's used
*
* Example:
* Balanced.Test.reopenClass({
* deserialize: function(json) {
* json.anotherProperty = "value";
* }
* });
*/
deserialize: null,
find: function (uri, settings) {
var modelClass = this;
var modelObject = modelClass.create({uri: uri});
modelObject.set('isLoaded', false);
modelObject.set('isNew', false);
// pull out the observer if it's present
settings = settings || {};
var observer = settings.observer;
if (observer) {
// this allows us to subscribe to events on this object without
// worrying about any race conditions
modelObject.addObserver('isLoaded', observer);
}
Balanced.Adapter.get(modelClass, uri, function (json) {
modelObject._updateFromJson(json);
modelObject.set('isLoaded', true);
modelObject.trigger('didLoad');
});
return modelObject;
},
belongsTo: function (type, propertyName, settings) {
var modelClass = this;
return Ember.computed(function () {
settings = settings || {};
var typeClass = modelClass._typeClass(type);
// if the property hasn't been set yet, don't bother trying to load it
if (this.get(propertyName)) {
if (settings.embedded) {
var embeddedObj = typeClass.create();
embeddedObj.set('isNew', false);
embeddedObj._updateFromJson(this.get(propertyName));
return embeddedObj;
} else {
return typeClass.find(this.get(propertyName));
}
} else {
return null;
}
}).property(propertyName);
},
hasMany: function (type, propertyName, settings) {
var modelClass = this;
return Ember.computed(function () {
settings = settings || {};
var typeClass = modelClass._typeClass(type);
var modelObjectsArray = Ember.A();
// if the property hasn't been set yet, don't bother trying to load it
if (this.get(propertyName)) {
var populateModels = function (json) {
if (json && json.items) {
var typedObjects = _.map(json.items, function (item) {
var typedObj = typeClass.create();
typedObj.set('isNew', false);
typedObj._updateFromJson(item);
// if an object is deleted, remove it from the collection
typedObj.on('didDelete', function () {
modelObjectsArray.removeObject(typedObj);
});
return typedObj;
});
modelObjectsArray.setObjects(typedObjects);
modelObjectsArray.set('isLoaded', true);
} else {
modelObjectsArray.set('isError', true);
}
};
if (settings.embedded) {
populateModels(this.get(propertyName));
} else {
modelObjectsArray.set('isLoaded', false);
Balanced.Adapter.get(typeClass, this.get(propertyName), function (json) {
populateModels(json);
});
}
}
return modelObjectsArray;
}).property(propertyName);
},
_typeClass: function (type) {
// allow dependencies to be set using strings instead of class
// statements so we don't have ordering issues when declaring our
// models
var typeClass = type;
// HACK: this gets around the jshint eval warning but let's clean this up.
var a = eval;
if (typeof type === 'string') {
typeClass = a(type);
}
return typeClass;
}
});
// https://github.com/billysbilling/billy-data/commit/9861697d587d87dd868fd8c516a0a0b1818990f7
BD.Model = Em.Object.extend(Em.Evented, {
isLoaded: false,
isNew: false,
isDeleted: false,
selfIsDirty: false,
childIsDirty: false,
clientId: null,
id: null,
_data: null,
error: null,
errors: null,
init: function() {
this._hasManyRecordArrays = {};
this._deletedEmbeddedRecords = [];
this._inRecordArrays = {};
this.set('_data', {
attributes: {},
belongsTo: {},
hasMany: {}
});
BD.store.didInstantiateRecord(this);
this._super();
},
reference: function() {
return Ember.get(this.constructor, 'root') + ':' + this.get('id');
}.property('id'),
eachAttribute: function(callback, binding) {
Em.get(this.constructor, 'attributes').forEach(function(key, meta) {
callback.call(binding, key, meta);
});
},
eachBelongsTo: function(callback, binding) {
Em.get(this.constructor, 'belongsToRelationships').forEach(function(key, meta) {
callback.call(binding, key, meta);
});
},
_parentRelationship: function() {
var parentRelationship = null;
this.eachBelongsTo(function(key, meta) {
if (meta.options.isParent) {
parentRelationship = key;
}
}, this);
return parentRelationship;
}.property(),
isEmbedded: function() {
return !!this.get('_parentRelationship');
}.property(),
getParent: function() {
var parentRelationship = this.get('_parentRelationship');
return parentRelationship ? this.get(parentRelationship) : null;
},
eachHasMany: function(callback, binding) {
Em.get(this.constructor, 'hasManyRelationships').forEach(function(key, meta) {
callback.call(binding, key, meta);
});
},
eachEmbeddedHasMany: function(callback, binding) {
this.eachHasMany(function(key, meta) {
if (meta.options.isEmbedded) {
callback.call(binding, key, meta)
}
})
},
eachEmbeddedRecord: function(callback, binding) {
this.eachEmbeddedHasMany(function(key, meta) {
//Skip unloaded records
if (!this.hasManyIsLoaded(key)) {
return;
}
var records = this.get(key);
records.forEach(function(r) {
callback.call(binding, r, key, meta);
});
}, this)
},
hasManyIsLoaded: function(key) {
return this._hasManyRecordArrays[key];
},
didDefineProperty: function(proto, key, value) {
if (value instanceof Ember.Descriptor) {
var meta = value.meta();
if (meta.isAttribute || meta.isBelongsTo) {
Ember.addObserver(proto, key, null, '_attributeDidChange');
}
}
},
_attributeDidChange: function(r, key) {
BD.store.recordAttributeDidChange(this, key);
},
loadData: function(serialized) {
this._serializedData = serialized;
},
materializeData: function() {
var serialized = this._serializedData,
data = this.get('_data');
BD.store.suspendRecordAttributeDidChange();
//Attributes
this.eachAttribute(function(key, meta) {
data.attributes[key] = BD.transforms[meta.type].deserialize(serialized[key]);
BD.store.recordAttributeDidChange(this, key);
}, this);
//BelongsTo
this.eachBelongsTo(function(key, meta) {
var newValue = meta.extractValue(serialized, key);
data.belongsTo[key] = newValue;
BD.store.recordAttributeDidChange(this, key);
}, this);
//HasMany
this.eachHasMany(function(key, meta) {
var ids = serialized[BD.singularize(key)+'Ids'];
if (ids) {
data.hasMany[key] = ids;
this.get(key);
}
}, this);
//
Em.propertyDidChange(this, '_data');
BD.store.resumeRecordAttributeDidChange();
//
delete this._serializedData;
},
whenLoaded: function(callback) {
if (this.get('isLoaded')) {
callback();
} else {
this.one('didLoad', callback);
}
},
include: function(include) {
BD.store.findByIdInclude(this.constructor, this.get('id'), include);
},
isDirty: function() {
return (this.get('selfIsDirty') || this.get('childIsDirty'));
}.property('selfIsDirty', 'childIsDirty'),
becomeDirty: function() {
if (this.get('isUnloaded') || this.get('selfIsDirty')) {
return;
}
var data = this.get('_data'),
parent;
this.clean = {
isNew: this.get('isNew'),
data: {
attributes: Em.copy(data.attributes),
belongsTo: Em.copy(data.belongsTo),
hasMany: Em.copy(data.hasMany, true)
}
};
this.set('selfIsDirty', true);
//Dirty parent
parent = this.getParent();
if (parent) {
parent.checkEmbeddedChildrenDirty();
}
},
checkEmbeddedChildrenDirty: function() {
var childIsDirty = false;
if (this._deletedEmbeddedRecords.length > 0) {
childIsDirty = true;
}
this.eachEmbeddedRecord(function(r) {
if (r.get('isDirty')) {
childIsDirty = true;
}
});
this.set('childIsDirty', childIsDirty);
},
didDeleteEmbeddedRecord: function(r) {
this.checkEmbeddedChildrenDirty();
this._deletedEmbeddedRecords.push(r);
},
becameClean: function() {
this.checkEmbeddedChildrenDirty();
if (!this.get('selfIsDirty')) {
return;
}
delete this.clean;
this.set('selfIsDirty', false);
},
didCommit: function(options) {
this.setProperties(options.properties);
this.eachHasMany(function(key) {
if (options.embed.contains(key)) {
this.get(key).forEach(function(child) {
child.becameClean();
});
}
}, this);
this.becameClean();
},
rollback: function() {
//Setup data variables
var selfIsDirty = this.get('selfIsDirty');
//Rollback embedded records. We have to take these directly from BOTH the dirty- and clean data, if the record itself is dirty
this.eachEmbeddedRecord(function(r) {
r.rollback();
}, this);
this._deletedEmbeddedRecords.forEach(function(r) {
r.rollback();
});
this._deletedEmbeddedRecords = [];
this.checkEmbeddedChildrenDirty();
//Don't continue if this record itself is not dirty
if (!selfIsDirty) {
return;
}
//Store dirty parent (before we might clean it and set it back the original parent)
var dirtyParent = this.getParent();
//Set the data to the clean data
this.set('_data', this.clean.data);
//Handle the case where the record is not newly created
if (!this.clean.isNew) {
if (this.get('isDeleted')) {
this.set('isDeleted', false);
}
this.becameClean();
} else {
//Handle case where record never was created. Then we just unload it
this.unload();
}
//Let parent check child dirtiness
if (dirtyParent) {
dirtyParent.checkEmbeddedChildrenDirty();
}
},
save: function(options) {
return BD.store.saveRecord(this, options);
},
deleteRecord: function() {
return BD.store.deleteRecord(this);
},
serialize: function(options) {
options = options || {};
var serialized = {},
optionProperties = options.properties || {},
data = this.get('_data');
serialized._clientId = this.get('clientId');
if (!this.get('isNew')) {
serialized.id = this.get('id');
}
this.eachAttribute(function(key, meta) {
if (!meta.options.readonly) {
var value = optionProperties.hasOwnProperty(key) ? optionProperties[key] : data.attributes[key];
if (typeof value !== 'undefined') {
serialized[key] = BD.transforms[meta.type].serialize(value);
}
}
}, this);
this.eachBelongsTo(function(key, meta) {
if (!meta.options.readonly && (!options.isEmbedded || !meta.options.isParent)) {
var id;
if (optionProperties.hasOwnProperty(key)) {
id = optionProperties[key] ? optionProperties[key].get('id') : null;
} else {
id = data.belongsTo[key];
}
if (id && typeof id === 'object') {
id = BD.store.findByClientId(id.clientId).get(meta.idProperty);
}
if (typeof id !== 'undefined') {
meta.serialize(serialized, key, id);
}
}
}, this);
if (options.embed) {
this.eachHasMany(function(key, meta) {
if (options.embed.contains(key)) {
var embeddedRecords = [];
this.get(key).forEach(function(child) {
embeddedRecords.push(child.serialize({isEmbedded: true}));
});
serialized[key] = embeddedRecords;
}
}, this);
}
return serialized;
},
didAddToRecordArray: function(recordArray) {
this._inRecordArrays[Em.guidFor(recordArray)] = recordArray;
},
didRemoveFromRecordArray: function(recordArray) {
delete this._inRecordArrays[Em.guidFor(recordArray)];
},
isInRecordArray: function(recordArray) {
return !!this._inRecordArrays[Em.guidFor(recordArray)]
},
unload: function() {
this.set('isUnloaded', true);
this.eachBelongsTo(function(name) {
this.set(name, null);
}, this);
this.eachEmbeddedRecord(function(child) {
child.unload();
});
_.each(this._inRecordArrays, function(recordArray) {
recordArray.removeObject(this);
}, this);
BD.store.didUnloadRecord(this);
this.destroy();
},
toString: function toString() {
return '<'+this.constructor.toString()+':'+this.get('id')+':'+this.get('clientId')+'>';
}
});
BD.Model.reopenClass({
_create: BD.Model.create,
create: function() {
throw new Ember.Error("You should not call `create` on a model. Instead, call `createRecord` with the attributes you would like to set.");
},
createRecord: function(properties) {
//Instantiate record
properties = properties || {};
var r = this._create({
isNew: true,
isLoaded: true
});
//Make sure that each hasMany relationship is registered as an empty RecordArray
var data = r.get('_data');
r.eachHasMany(function(name) {
data.hasMany[name] = [];
r.get(name);
}, this);
//Mark the record as dirty and update properties
r.becomeDirty();
BD.store.suspendRecordAttributeDidChange();
r.setProperties(properties);
BD.store.resumeRecordAttributeDidChange();
return r;
},
root: function() {
var typeString = this.toString();
var parts = typeString.split(".");
var name = parts[parts.length - 1];
name = name.substring(0, 1).toLowerCase() + name.substring(1);
return name;
}.property(),
attributes: function() {
var map = Ember.Map.create();
this.eachComputedProperty(function(key, meta) {
if (meta.isAttribute) {
meta.key = key;
map.set(key, meta);
}
});
return map;
}.property(),
belongsToRelationships: function() {
var map = Ember.Map.create();
this.eachComputedProperty(function(key, meta) {
if (meta.isBelongsTo) {
meta.key = key;
map.set(key, meta);
}
});
return map;
}.property(),
hasManyRelationships: function() {
var map = Ember.Map.create();
this.eachComputedProperty(function(key, meta) {
if (meta.isHasMany) {
meta.key = key;
map.set(key, meta);
}
});
return map;
}.property(),
find: function(id) {
return BD.store.find(this, id);
},
findMany: function(ids) {
return BD.store.findMany(this, ids);
},
findByUrl: function(url, query) {
return BD.store.findByUrl(this, url, query);
},
findByQuery: function(query) {
return BD.store.findByQuery(this, query);
},
findByIdQuery: function(id, query) {
return BD.store.findByIdQuery(this, id, query);
},
findByIdInclude: function(id, include) {
return BD.store.findByIdInclude(this, id, include);
},
all: function() {
return BD.store.all(this);
},
allLocal: function() {
return BD.store.allLocal(this);
},
filter: function(filter, comparator) {
return BD.store.filter(this, filter, comparator);
},
loadAll: function(dataItems) {
return BD.store.loadAll(this, dataItems);
},
loadMany: function(dataItems) {
return BD.store.loadMany(this, dataItems);
},
load: function(data) {
return BD.store.load(this, data);
}
});
// https://github.com/emberjs/data/commit/74df8304aad6aa25631414bd629db13c6e7c8a2c
require("ember-data/system/model/states");
require("ember-data/system/mixins/load_promise");
var LoadPromise = DS.LoadPromise; // system/mixins/load_promise
var get = Ember.get, set = Ember.set, map = Ember.EnumerableUtils.map;
var retrieveFromCurrentState = Ember.computed(function(key, value) {
return get(get(this, 'stateManager.currentState'), key);
}).property('stateManager.currentState').readOnly();
/**
The model class that all Ember Data records descend from.
@module data
@submodule data-model
@main data-model
@class Model
@namespace DS
@extends Ember.Object
@constructor
*/
DS.Model = Ember.Object.extend(Ember.Evented, LoadPromise, {
isLoading: retrieveFromCurrentState,
isLoaded: retrieveFromCurrentState,
isReloading: retrieveFromCurrentState,
isDirty: retrieveFromCurrentState,
isSaving: retrieveFromCurrentState,
isDeleted: retrieveFromCurrentState,
isError: retrieveFromCurrentState,
isNew: retrieveFromCurrentState,
isValid: retrieveFromCurrentState,
dirtyType: retrieveFromCurrentState,
clientId: null,
id: null,
transaction: null,
stateManager: null,
errors: null,
/**
Create a JSON representation of the record, using the serialization
strategy of the store's adapter.
@method serialize
@param {Object} options Available options:
* `includeId`: `true` if the record's ID should be included in the
JSON representation.
@returns {Object} an object whose values are primitive JSON values only
*/
serialize: function(options) {
var store = get(this, 'store');
return store.serialize(this, options);
},
/**
Use {{#crossLink "DS.JSONSerializer"}}DS.JSONSerializer{{/crossLink}} to
get the JSON representation of a record.
@method toJSON
@param {Object} options Available options:
* `includeId`: `true` if the record's ID should be included in the
JSON representation.
@returns {Object} A JSON representation of the object.
*/
toJSON: function(options) {
var serializer = DS.JSONSerializer.create();
return serializer.serialize(this, options);
},
/**
Fired when the record is loaded from the server.
@event didLoad
*/
didLoad: Ember.K,
/**
Fired when the record is reloaded from the server.
@event didReload
*/
didReload: Ember.K,
/**
Fired when the record is updated.
@event didUpdate
*/
didUpdate: Ember.K,
/**
Fired when the record is created.
@event didCreate
*/
didCreate: Ember.K,
/**
Fired when the record is deleted.
@event didDelete
*/
didDelete: Ember.K,
/**
Fired when the record becomes invalid.
@event becameInvalid
*/
becameInvalid: Ember.K,
/**
Fired when the record enters the error state.
@event becameError
*/
becameError: Ember.K,
data: Ember.computed(function() {
if (!this._data) {
this.setupData();
}
return this._data;
}).property(),
materializeData: function() {
this.send('materializingData');
get(this, 'store').materializeData(this);
this.suspendRelationshipObservers(function() {
this.notifyPropertyChange('data');
});
},
_data: null,
init: function() {
this._super();
var stateManager = DS.StateManager.create({ record: this });
set(this, 'stateManager', stateManager);
this._setup();
stateManager.goToState('empty');
},
_setup: function() {
this._changesToSync = {};
},
send: function(name, context) {
return get(this, 'stateManager').send(name, context);
},
withTransaction: function(fn) {
var transaction = get(this, 'transaction');
if (transaction) { fn(transaction); }
},
loadingData: function() {
this.send('loadingData');
},
loadedData: function() {
this.send('loadedData');
},
didChangeData: function() {
this.send('didChangeData');
},
deleteRecord: function() {
this.send('deleteRecord');
},
unloadRecord: function() {
Ember.assert("You can only unload a loaded, non-dirty record.", !get(this, 'isDirty'));
this.send('unloadRecord');
},
clearRelationships: function() {
this.eachRelationship(function(name, relationship) {
if (relationship.kind === 'belongsTo') {
set(this, name, null);
} else if (relationship.kind === 'hasMany') {
this.clearHasMany(relationship);
}
}, this);
},
updateRecordArrays: function() {
var store = get(this, 'store');
if (store) {
store.dataWasUpdated(this.constructor, get(this, '_reference'), this);
}
},
/**
If the adapter did not return a hash in response to a commit,
merge the changed attributes and relationships into the existing
saved data.
*/
adapterDidCommit: function() {
var attributes = get(this, 'data').attributes;
get(this.constructor, 'attributes').forEach(function(name, meta) {
attributes[name] = get(this, name);
}, this);
this.send('didCommit');
this.updateRecordArraysLater();
},
adapterDidDirty: function() {
this.send('becomeDirty');
this.updateRecordArraysLater();
},
dataDidChange: Ember.observer(function() {
this.reloadHasManys();
this.send('finishedMaterializing');
}, 'data'),
reloadHasManys: function() {
var relationships = get(this.constructor, 'relationshipsByName');
this.updateRecordArraysLater();
relationships.forEach(function(name, relationship) {
if (relationship.kind === 'hasMany') {
this.hasManyDidChange(relationship.key);
}
}, this);
},
hasManyDidChange: function(key) {
var cachedValue = this.cacheFor(key);
if (cachedValue) {
var type = get(this.constructor, 'relationshipsByName').get(key).type;
var store = get(this, 'store');
var ids = this._data.hasMany[key] || [];
var references = map(ids, function(id) {
if (typeof id === 'object') {
if( id.clientId ) {
// if it was already a reference, return the reference
return id;
} else {
// <id, type> tuple for a polymorphic association.
return store.referenceForId(id.type, id.id);
}
}
return store.referenceForId(type, id);
});
set(cachedValue, 'content', Ember.A(references));
}
},
updateRecordArraysLater: function() {
Ember.run.once(this, this.updateRecordArrays);
},
setupData: function() {
this._data = {
attributes: {},
belongsTo: {},
hasMany: {},
id: null
};
},
materializeId: function(id) {
set(this, 'id', id);
},
materializeAttributes: function(attributes) {
Ember.assert("Must pass a hash of attributes to materializeAttributes", !!attributes);
this._data.attributes = attributes;
},
materializeAttribute: function(name, value) {
this._data.attributes[name] = value;
},
materializeHasMany: function(name, tuplesOrReferencesOrOpaque) {
var tuplesOrReferencesOrOpaqueType = typeof tuplesOrReferencesOrOpaque;
if (tuplesOrReferencesOrOpaque && tuplesOrReferencesOrOpaqueType !== 'string' && tuplesOrReferencesOrOpaque.length > 1) { Ember.assert('materializeHasMany expects tuples, references or opaque token, not ' + tuplesOrReferencesOrOpaque[0], tuplesOrReferencesOrOpaque[0].hasOwnProperty('id') && tuplesOrReferencesOrOpaque[0].type); }
if( tuplesOrReferencesOrOpaqueType === "string" ) {
this._data.hasMany[name] = tuplesOrReferencesOrOpaque;
} else {
var references = tuplesOrReferencesOrOpaque;
if (tuplesOrReferencesOrOpaque && Ember.isArray(tuplesOrReferencesOrOpaque)) {
references = this._convertTuplesToReferences(tuplesOrReferencesOrOpaque);
}
this._data.hasMany[name] = references;
}
},
materializeBelongsTo: function(name, tupleOrReference) {
if (tupleOrReference) { Ember.assert('materializeBelongsTo expects a tuple or a reference, not a ' + tupleOrReference, !tupleOrReference || (tupleOrReference.hasOwnProperty('id') && tupleOrReference.hasOwnProperty('type'))); }
this._data.belongsTo[name] = tupleOrReference;
},
_convertTuplesToReferences: function(tuplesOrReferences) {
return map(tuplesOrReferences, function(tupleOrReference) {
return this._convertTupleToReference(tupleOrReference);
}, this);
},
_convertTupleToReference: function(tupleOrReference) {
var store = get(this, 'store');
if(tupleOrReference.clientId) {
return tupleOrReference;
} else {
return store.referenceForId(tupleOrReference.type, tupleOrReference.id);
}
},
rollback: function() {
this._setup();
this.send('becameClean');
this.suspendRelationshipObservers(function() {
this.notifyPropertyChange('data');
});
},
toStringExtension: function() {
return get(this, 'id');
},
/**
@private
The goal of this method is to temporarily disable specific observers
that take action in response to application changes.
This allows the system to make changes (such as materialization and
rollback) that should not trigger secondary behavior (such as setting an
inverse relationship or marking records as dirty).
The specific implementation will likely change as Ember proper provides
better infrastructure for suspending groups of observers, and if Array
observation becomes more unified with regular observers.
*/
suspendRelationshipObservers: function(callback, binding) {
var observers = get(this.constructor, 'relationshipNames').belongsTo;
var self = this;
try {
this._suspendedRelationships = true;
Ember._suspendObservers(self, observers, null, 'belongsToDidChange', function() {
Ember._suspendBeforeObservers(self, observers, null, 'belongsToWillChange', function() {
callback.call(binding || self);
});
});
} finally {
this._suspendedRelationships = false;
}
},
becameInFlight: function() {
},
/**
@private
*/
resolveOn: function(successEvent) {
var model = this;
return new Ember.RSVP.Promise(function(resolve, reject) {
function success() {
this.off('becameError', error);
this.off('becameInvalid', error);
resolve(this);
}
function error() {
this.off(successEvent, success);
reject(this);
}
model.one(successEvent, success);
model.one('becameError', error);
model.one('becameInvalid', error);
});
},
/**
Save the record.
@method save
*/
save: function() {
this.get('store').scheduleSave(this);
return this.resolveOn('didCommit');
},
/**
Reload the record from the adapter.
This will only work if the record has already finished loading
and has not yet been modified (`isLoaded` but not `isDirty`,
or `isSaving`).
@method reload
*/
reload: function() {
this.send('reloadRecord');
return this.resolveOn('didReload');
},
// FOR USE DURING COMMIT PROCESS
adapterDidUpdateAttribute: function(attributeName, value) {
// If a value is passed in, update the internal attributes and clear
// the attribute cache so it picks up the new value. Otherwise,
// collapse the current value into the internal attributes because
// the adapter has acknowledged it.
if (value !== undefined) {
get(this, 'data.attributes')[attributeName] = value;
this.notifyPropertyChange(attributeName);
} else {
value = get(this, attributeName);
get(this, 'data.attributes')[attributeName] = value;
}
this.updateRecordArraysLater();
},
adapterDidInvalidate: function(errors) {
this.send('becameInvalid', errors);
},
adapterDidError: function() {
this.send('becameError');
},
/**
@private
Override the default event firing from Ember.Evented to
also call methods with the given name.
*/
trigger: function(name) {
Ember.tryInvoke(this, name, [].slice.call(arguments, 1));
this._super.apply(this, arguments);
}
});
// Helper function to generate store aliases.
// This returns a function that invokes the named alias
// on the default store, but injects the class as the
// first parameter.
var storeAlias = function(methodName) {
return function() {
var store = get(DS, 'defaultStore'),
args = [].slice.call(arguments);
args.unshift(this);
Ember.assert("Your application does not have a 'Store' property defined. Attempts to call '" + methodName + "' on model classes will fail. Please provide one as with 'YourAppName.Store = DS.Store.extend()'", !!store);
return store[methodName].apply(store, args);
};
};
DS.Model.reopenClass({
/** @private
Alias DS.Model's `create` method to `_create`. This allows us to create DS.Model
instances from within the store, but if end users accidentally call `create()`
(instead of `createRecord()`), we can raise an error.
*/
_create: DS.Model.create,
/** @private
Override the class' `create()` method to raise an error. This prevents end users
from inadvertently calling `create()` instead of `createRecord()`. The store is
still able to create instances by calling the `_create()` method.
*/
create: function() {
throw new Ember.Error("You should not call `create` on a model. Instead, call `createRecord` with the attributes you would like to set.");
},
/**
See {{#crossLink "DS.Store/find:method"}}`DS.Store.find()`{{/crossLink}}.
@method find
@param {Object|String|Array|null} query A query to find records by.
*/
find: storeAlias('find'),
/**
See {{#crossLink "DS.Store/all:method"}}`DS.Store.all()`{{/crossLink}}.
@method all
@return {DS.RecordArray}
*/
all: storeAlias('all'),
/**
See {{#crossLink "DS.Store/findQuery:method"}}`DS.Store.findQuery()`{{/crossLink}}.
@method query
@param {Object} query an opaque query to be used by the adapter
@return {DS.AdapterPopulatedRecordArray}
*/
query: storeAlias('findQuery'),
/**
See {{#crossLink "DS.Store/filter:method"}}`DS.Store.filter()`{{/crossLink}}.
@method filter
@param {Function} filter
@return {DS.FilteredRecordArray}
*/
filter: storeAlias('filter'),
/**
See {{#crossLink "DS.Store/createRecord:method"}}`DS.Store.createRecord()`{{/crossLink}}.
@method createRecord
@param {Object} properties a hash of properties to set on the
newly created record.
@returns DS.Model
*/
createRecord: storeAlias('createRecord')
});
// https://github.com/ebryn/ember-model/commit/7656e631b7b67cd21efe6f44a36e0fc5a7160c9b
require('ember-model/adapter');
require('ember-model/record_array');
var get = Ember.get,
set = Ember.set,
meta = Ember.meta;
function contains(array, element) {
for (var i = 0, l = array.length; i < l; i++) {
if (array[i] === element) { return true; }
}
return false;
}
function concatUnique(toArray, fromArray) {
var e;
for (var i = 0, l = fromArray.length; i < l; i++) {
e = fromArray[i];
if (!contains(toArray, e)) { toArray.push(e); }
}
return toArray;
}
Ember.run.queues.push('data');
Ember.Model = Ember.Object.extend(Ember.Evented, Ember.DeferredMixin, {
isLoaded: true,
isLoading: Ember.computed.not('isLoaded'),
isNew: true,
isDeleted: false,
_dirtyAttributes: null,
// TODO: rewrite w/o volatile
isDirty: Ember.computed(function() {
var attributes = this.attributes,
dirtyAttributes = this._dirtyAttributes,
key, cachedValue, dataValue, desc, descMeta, type, isDirty;
for (var i = 0, l = attributes.length; i < l; i++) {
key = attributes[i];
cachedValue = this.cacheFor(key);
dataValue = get(this, 'data.'+key);
desc = meta(this).descs[key];
descMeta = desc && desc.meta();
type = descMeta.type;
isDirty = dirtyAttributes && dirtyAttributes.indexOf(key) !== -1;
if (!isDirty && type && type.isEqual) {
if (!type.isEqual(dataValue, cachedValue || dataValue)) { // computed property won't have a value when just loaded
if (!dirtyAttributes) {
dirtyAttributes = this._dirtyAttributes = Ember.A();
}
dirtyAttributes.push(key);
}
}
}
return dirtyAttributes && dirtyAttributes.length !== 0;
}).property().volatile(),
init: function() {
if (!get(this, 'isNew')) { this.resolve(this); }
this._super();
},
load: function(id, hash) {
var data = Ember.merge({id: id}, hash);
set(this, 'data', data);
set(this, 'isLoaded', true);
set(this, 'isNew', false);
this.trigger('didLoad');
this.resolve(this);
},
didDefineProperty: function(proto, key, value) {
if (value instanceof Ember.Descriptor) {
var meta = value.meta();
if (meta.isAttribute) {
if (!proto.attributes) { proto.attributes = []; }
proto.attributes.push(key);
}
}
},
toJSON: function() {
return this.getProperties(this.attributes);
},
save: function() {
var adapter = this.constructor.adapter;
set(this, 'isSaving', true);
if (get(this, 'isNew')) {
return adapter.createRecord(this);
} else if (get(this, 'isDirty')) {
return adapter.saveRecord(this);
} else {
var deferred = Ember.Deferred.create();
deferred.resolve(this);
return deferred;
}
},
didCreateRecord: function() {
set(this, 'isNew', false);
this.load(this.get('id'), this.getProperties(this.attributes));
this.constructor.addToRecordArrays(this);
this.trigger('didCreateRecord');
this.didSaveRecord();
},
didSaveRecord: function() {
set(this, 'isSaving', false);
this.trigger('didSaveRecord');
this._copyDirtyAttributesToData();
},
deleteRecord: function() {
return this.constructor.adapter.deleteRecord(this);
},
didDeleteRecord: function() {
this.constructor.removeFromRecordArrays(this);
set(this, 'isDeleted', true);
this.trigger('didDeleteRecord');
},
_copyDirtyAttributesToData: function() {
if (!this._dirtyAttributes) { return; }
var dirtyAttributes = this._dirtyAttributes,
data = get(this, 'data'),
key;
if (!data) {
data = {};
set(this, 'data', data);
}
for (var i = 0, l = dirtyAttributes.length; i < l; i++) {
// TODO: merge Object.create'd object into prototype
key = dirtyAttributes[i];
data[key] = this.cacheFor(key);
}
this._dirtyAttributes = [];
}
});
Ember.Model.reopenClass({
adapter: Ember.Adapter.create(),
find: function(id) {
if (!arguments.length) {
return this.findAll();
} else if (Ember.isArray(id)) {
return this.findMany(id);
} else if (typeof id === 'object') {
return this.findQuery(id);
} else {
return this.findById(id);
}
},
findMany: function(ids) {
Ember.assert("findMany requires an array", Ember.isArray(ids));
var records = Ember.RecordArray.create({_ids: ids});
if (!this.recordArrays) { this.recordArrays = []; }
this.recordArrays.push(records);
if (this._currentBatchIds) {
concatUnique(this._currentBatchIds, ids);
this._currentBatchRecordArrays.push(records);
} else {
this._currentBatchIds = concatUnique([], ids);
this._currentBatchRecordArrays = [records];
}
Ember.run.scheduleOnce('data', this, this._executeBatch);
return records;
},
findAll: function() {
if (this._findAllRecordArray) { return this._findAllRecordArray; }
var records = this._findAllRecordArray = Ember.RecordArray.create();
this.adapter.findAll(this, records);
return records;
},
_currentBatchIds: null,
_currentBatchRecordArrays: null,
findById: function(id) {
var record = this.cachedRecordForId(id),
adapter = get(this, 'adapter');
if (!get(record, 'isLoaded')) {
if (adapter.findMany) {
if (this._currentBatchIds) {
if (!contains(this._currentBatchIds, id)) { this._currentBatchIds.push(id); }
} else {
this._currentBatchIds = [id];
this._currentBatchRecordArrays = [];
}
Ember.run.scheduleOnce('data', this, this._executeBatch);
} else {
adapter.find(record, id);
}
}
return record;
},
_executeBatch: function() {
var batchIds = this._currentBatchIds,
batchRecordArrays = this._currentBatchRecordArrays,
self = this,
records;
this._currentBatchIds = null;
this._currentBatchRecordArrays = null;
if (batchIds.length === 1) {
get(this, 'adapter').find(this.cachedRecordForId(batchIds[0]), batchIds[0]);
} else {
records = Ember.RecordArray.create({_ids: batchIds}),
get(this, 'adapter').findMany(this, records, batchIds);
records.then(function() {
for (var i = 0, l = batchRecordArrays.length; i < l; i++) {
batchRecordArrays[i].loadForFindMany(self);
}
});
}
},
findQuery: function(params) {
var records = Ember.RecordArray.create();
this.adapter.findQuery(this, records, params);
return records;
},
cachedRecordForId: function(id) {
if (!this.recordCache) { this.recordCache = {}; }
var sideloadedData = this.sideloadedData && this.sideloadedData[id];
var record = this.recordCache[id] || (sideloadedData ? this.create(sideloadedData) : this.create({isLoaded: false}));
if (!this.recordCache[id]) { this.recordCache[id] = record; }
return record;
},
addToRecordArrays: function(record) {
if (this._findAllRecordArray) {
this._findAllRecordArray.pushObject(record);
}
if (this.recordArrays) {
this.recordArrays.forEach(function(recordArray) {
if (recordArray instanceof Ember.FilteredRecordArray) {
recordArray.registerObserversOnRecord(record);
recordArray.updateFilter();
} else {
recordArray.pushObject(record);
}
});
}
},
removeFromRecordArrays: function(record) {
if (this._findAllRecordArray) {
this._findAllRecordArray.removeObject(record);
}
if (this.recordArrays) {
this.recordArrays.forEach(function(recordArray) {
recordArray.removeObject(record);
});
}
},
// FIXME
findFromCacheOrLoad: function(data) {
var record = this.cachedRecordForId(data.id);
// set(record, 'data', data);
record.load(data.id, data);
return record;
},
registerRecordArray: function(recordArray) {
if (!this.recordArrays) { this.recordArrays = []; }
this.recordArrays.push(recordArray);
},
unregisterRecordArray: function(recordArray) {
if (!this.recordArrays) { return; }
Ember.A(this.recordArrays).removeObject(recordArray);
},
forEachCachedRecord: function(callback) {
if (!this.recordCache) { return Ember.A([]); }
var ids = Object.keys(this.recordCache);
ids.map(function(id) {
return this.recordCache[parseInt(id, 10)];
}, this).forEach(callback);
},
load: function(hashes) {
if (!this.sideloadedData) { this.sideloadedData = {}; }
for (var i = 0, l = hashes.length; i < l; i++) {
var hash = hashes[i];
this.sideloadedData[hash.id] = hash; // FIXME: hardcoding `id` property
}
}
});
// https://github.com/charlieridley/emu/commit/427ad7acf9af9ab790683427f597db7e0191e21f
Emu.Model = Ember.Object.extend Emu.ModelEvented, Emu.StateTracked, Ember.Evented,
init: ->
@_super()
unless @get("store")
@set("store", Ember.get(Emu, "defaultStore"))
@_primaryKey = Emu.Model.primaryKey(@constructor)
@set("isDirty", true) if @get("isDirty") == undefined
save: ->
@get("store").save(this)
subscribeToUpdates: ->
@get("store").subscribeToUpdates(this)
primaryKey: -> @_primaryKey or @_primaryKey = Emu.Model.primaryKey(@constructor)
primaryKeyValue: (value) ->
if value
@set(@primaryKey(), value)
@set("hasValue", true)
@get(@primaryKey())
clear: ->
@constructor.eachEmuField (property, meta) =>
if meta.isModel() or meta.options.collection
Emu.Model.getAttr(this, property).clear()
else
@set(property, undefined)
@set("hasValue", false)
Emu.proxyToStore = (methodName) ->
->
store = Ember.get(Emu, "defaultStore")
args = [].slice.call(arguments)
args.unshift(this)
Ember.assert("Cannot call " + methodName + ". You need define a store first like this: App.Store = Emu.Store.extend()", !!store)
store[methodName].apply(store, args)
Emu.Model.reopenClass
isEmuModel: true
createRecord: Emu.proxyToStore("createRecord")
find: Emu.proxyToStore("find")
findPaged: Emu.proxyToStore("findPaged")
primaryKey: (type = this) ->
primaryKey = "id"
primaryKeyCount = 0
type.eachComputedProperty (property, meta) =>
if meta.options?.primaryKey
primaryKey = property
primaryKeyCount++
if primaryKeyCount > 1
throw new Error("Error with #{this}: You can only mark one field as a primary key")
primaryKey
eachEmuField: (callback) ->
@eachComputedProperty (property, meta) ->
if meta.isField
callback(property, meta)
getAttr: (record, key) ->
meta = record.constructor.metaForProperty(key)
record._attributes ?= {}
unless record._attributes[key]
if meta.options.collection
collectionType = if meta.options.paged then Emu.PagedModelCollection else Emu.ModelCollection
record._attributes[key] = collectionType.create(parent: record, type: meta.type(), store: record.get("store"), lazy: meta.options.lazy)
record._attributes[key].addObserver "hasValue", -> record.set("hasValue", true)
unless meta.options.lazy
record._attributes[key].on "didStateChange", ->
record.didStateChange()
record._attributes[key].subscribeToUpdates() if meta.options.updatable
else if meta.isModel()
record._attributes[key] = meta.type().create()
record._attributes[key].on "didStateChange", ->
record.didStateChange()
record._attributes[key]
setAttr: (record, key, value) ->
record._attributes ?= {}
record._attributes[key] = value
record.set("hasValue", true)
/**
A record array is an array that contains records of a certain type.
@class RecordArray
@extends Ember.ArrayProxy
@uses Ember.Evented
@uses Ember.Deferred
*/
DS.RecordArray = Ember.ArrayProxy.extend(Ember.Evented, Ember.Deferred, {
/**
The model type contained by this record array.
@type DS.Model
*/
type: null,
content: null,
isLoaded: false,
isUpdating: false
});

Ember-data has existed for quite a while and seems to actually have a very good api for interfacing w/ a model.

Maybe its time to make this more of a public interface as ember-data moves to be more in line w/ api's that follow jsonapi.org while other projects like ebryn's ember-model, emu, and newly created projects have taken this interface and created their own implementations for model

The missing piece in most of these implementations w/ a model class in ember is a caching layer or identity-map. That's where it gets really complicated. However, there might be a simple api hiding underneath all of the use cases. There should be an interface for an ability to cache records in memory or to override it in cache in something like localStorage. How is the million dollar question, but there are many implementations. It seems like creating an interface that is similar to localStorage but defaults to in memory would be the way to go.

Here is a tour of some javascript caching solutions that people have tried to implement in the browser:

  • MicroCache 2 Yrs ago Simple solution that has an internal object where key, values are stored. The api is simple: get() contains() remove() set() values() getSet()
  • JSCache Activity: 1 Month ago cache.setItem(key, object, options) cache.getItem() cache.removeItem() cache.removeWhere() cache.size() cache.stats() cache.clear() Includes ability to use localStorage instead of in memory
  • locache Activity: 9 Months ago locache.set(key, value, seconds) locache.get(key) locache.remove() locache.incr("counter") locache.decr("counter") locache.flush() locache.cleanup() localStorage api: clear() setItem() removeItem()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment