emberfire
Version:
The officially supported Ember binding for Firebase
890 lines (753 loc) • 26.8 kB
JavaScript
import Ember from 'ember';
import DS from 'ember-data';
import Waitable from '../mixins/waitable';
import toPromise from '../utils/to-promise';
const { assign, RSVP } = Ember;
const { Promise } = RSVP;
import { pluralize } from 'ember-inflector';
var uniq = function (arr) {
var ret = Ember.A();
arr.forEach(function(k) {
if (ret.indexOf(k) < 0) {
ret.push(k);
}
});
return ret;
};
var isInteger = Number.isInteger || function(value) {
return typeof value === 'number' &&
isFinite(value) &&
Math.floor(value) === value;
};
/**
* The Firebase adapter allows your store to communicate with the Firebase
* realtime service. To use the adapter in your app, extend DS.FirebaseAdapter
* and customize the endpoint to point to the Firebase URL where you want this
* data to be stored.
*
* The adapter will automatically communicate with Firebase to persist your
* records as neccessary. Importantly, the adapter will also update the store
* in realtime when changes are made to the Firebase by other clients or
* otherwise.
*/
export default DS.Adapter.extend(Waitable, {
firebase: Ember.inject.service(),
store: Ember.inject.service(),
defaultSerializer: '-firebase',
/**
* Endpoint paths can be customized by setting the Firebase property on the
* adapter:
*
* ```js
* DS.FirebaseAdapter.extend({
* firebase: new Firebase('https://<my-firebase>.firebaseio.com/')
* });
* ```
*
* Requests for `App.Post` now target `https://<my-firebase>.firebaseio.com/posts`.
*
* @property firebase
* @type {Firebase}
* @constructor
*/
init() {
this._super.apply(this, arguments);
var ref = this.get('firebase');
if (!ref) {
throw new Error('Please set the `firebase` property in the environment config.');
}
// If provided Firebase reference was a query (eg: limits), make it a ref.
this._ref = ref;
// Keep track of what types `.findAll()` has been called for
this._findAllMapForType = {};
// Keep a cache to check modified relationships against
this._recordCacheForType = {};
// Used to batch records into the store
this._queue = [];
// Payloads to push later
this._queuedPayloads = {};
},
/**
* Uses push() to generate chronologically ordered unique IDs.
*
* @return {String}
*/
generateIdForRecord() {
return this._getKey(this._ref.push());
},
/**
* Use the Firebase DataSnapshot's key as the record id
*
* @param {Object} snapshot - A Firebase snapshot
* @param {Object} payload - The payload that will be pushed into the store
* @return {Object} payload
*/
_assignIdToPayload(snapshot) {
var payload = snapshot.val();
if (payload !== null && typeof payload === 'object' && typeof payload.id === 'undefined') {
payload.id = this._getKey(snapshot);
}
return payload;
},
/**
* Called by the store to retrieve the JSON for a given type and ID. The
* method will return a promise which will resolve when the value is
* successfully fetched from Firebase.
*
* Additionally, from this point on, the object's value in the store will
* also be automatically updated whenever the remote value changes.
*/
findRecord(store, typeClass, id) {
var ref = this._getCollectionRef(typeClass, id);
var log = `DS: FirebaseAdapter#findRecord ${typeClass.modelName} to ${ref.toString()}`;
return this._fetch(ref, log).then((snapshot) => {
var payload = this._assignIdToPayload(snapshot);
this._updateRecordCacheForType(typeClass, payload, store);
if (payload === null) {
var error = new Error(`no record was found at ${ref.toString()}`);
error.recordId = id;
throw error;
}
return payload;
});
},
/**
* Promise interface for once('value').
*
* @param {Firebase} ref
* @param {String} log
* @return {Promise<DataSnapshot>}
* @private
*/
_fetch(ref, log) {
return RSVP.resolve(ref.once('value'), log);
},
recordWasPushed(store, modelName, record) {
if (!record.__listening) {
var typeClass = store.modelFor(modelName);
this.listenForChanges(store, typeClass, record);
}
},
recordWillUnload(store, record) {
if (record.__listening) {
this.stopListening(store, record.constructor, record);
}
},
recordWillDelete(store, record) {
record.eachRelationship((key, relationship) => {
if (relationship.kind === 'belongsTo') {
var parentRecord = record.get(relationship.key);
var inverseKey = record.inverseFor(relationship.key);
if (inverseKey && parentRecord.get('id')) {
var parentRef = this._getCollectionRef(inverseKey.type, parentRecord.get('id'));
this._removeHasManyRecord(store, parentRef, inverseKey.name, record.constructor, record.id);
}
}
});
},
listenForChanges(store, typeClass, record) {
// embedded records will get their changes from parent listeners
if (!this.isRecordEmbedded(record)) {
record.__listening = true;
var ref = this._getCollectionRef(typeClass, record.id);
var called = false;
ref.on('value', (snapshot) => {
if (called) {
Ember.run(() => {
this._handleChildValue(store, typeClass, snapshot);
});
}
called = true;
}, (error) => {
Ember.Logger.error(error);
});
}
},
stopListening(store, typeClass, record) {
if (record.__listening) {
var ref = this._getCollectionRef(typeClass, record.id);
ref.off('value');
record.__listening = false;
}
},
/**
* Called by the store to retrieve the JSON for all of the records for a
* given type. The method will return a promise which will resolve when the
* value is successfully fetched from Firebase.
*
* Additionally, from this point on, any records of this type that are added,
* removed or modified from Firebase will automatically be reflected in the
* store.
*/
findAll(store, typeClass) {
var ref = this._getCollectionRef(typeClass);
var log = `DS: FirebaseAdapter#findAll ${typeClass.modelName} to ${ref.toString()}`;
return this._fetch(ref, log).then((snapshot) => {
if (!this._findAllHasEventsForType(typeClass)) {
this._findAllAddEventListeners(store, typeClass, ref);
}
var results = [];
snapshot.forEach((childSnapshot) => {
var payload = this._assignIdToPayload(childSnapshot);
this._updateRecordCacheForType(typeClass, payload, store);
results.push(payload);
});
return results;
});
},
query(store, typeClass, query, recordArray) {
var ref = this._getCollectionRef(typeClass);
var modelName = typeClass.modelName;
ref = this.applyQueryToRef(ref, query);
ref.on('child_added', Ember.run.bind(this, function (snapshot) {
var record = store.peekRecord(modelName, this._getKey(snapshot));
if (!record || !record.__listening) {
var payload = this._assignIdToPayload(snapshot);
var normalizedData = store.normalize(typeClass.modelName, payload);
this._updateRecordCacheForType(typeClass, payload, store);
record = store.push(normalizedData);
}
if (record) {
recordArray.get('content').addObject(record._internalModel);
}
}));
// `child_changed` is already handled by the record's
// value listener after a store.push. `child_moved` is
// a much less common case because it relates to priority
ref.on('child_removed', Ember.run.bind(this, function (snapshot) {
var record = store.peekRecord(modelName, this._getKey(snapshot));
if (record) {
recordArray.get('content').removeObject(record._internalModel);
}
}));
// clean up event handlers when the array is being destroyed
// so that future firebase events wont keep trying to use a
// destroyed store/serializer
recordArray.__firebaseCleanup = function () {
ref.off('child_added');
ref.off('child_removed');
};
var log = `DS: FirebaseAdapter#query ${modelName} with ${query}`;
return this._fetch(ref, log).then((snapshot) => {
if (!this._findAllHasEventsForType(typeClass)) {
this._findAllAddEventListeners(store, typeClass, ref);
}
var results = [];
snapshot.forEach((childSnapshot) => {
var payload = this._assignIdToPayload(childSnapshot);
this._updateRecordCacheForType(typeClass, payload, store);
results.push(payload);
});
return results;
});
},
applyQueryToRef(ref, query) {
if (!query.orderBy) {
query.orderBy = '_key';
}
if (query.orderBy === '_key'){
ref = ref.orderByKey();
} else if (query.orderBy === '_value') {
ref = ref.orderByValue();
} else if (query.orderBy === '_priority') {
ref = ref.orderByPriority();
} else {
ref = ref.orderByChild(query.orderBy);
}
ref = this._applyRangesToRef(ref, query);
ref = this._applyLimitsToRef(ref, query);
return ref;
},
_applyRangesToRef(ref, query) {
const methods = ['equalTo', 'startAt', 'endAt'];
methods.forEach(key => {
if (query[key] !== undefined) {
ref = ref[key](query[key]);
}
});
return ref;
},
_applyLimitsToRef(ref, query) {
const methods = ['limitToFirst', 'limitToLast'];
methods.forEach(key => {
if (isInteger(query[key])) {
ref = ref[key](query[key]);
}
});
return ref;
},
/**
* Keep track of what types `.findAll()` has been called for
* so duplicate listeners aren't added
*/
_findAllMapForType: undefined,
/**
* Determine if the current type is already listening for children events
*/
_findAllHasEventsForType(typeClass) {
return !Ember.isNone(this._findAllMapForType[typeClass.modelName]);
},
/**
* After `.findAll()` is called on a modelName, continue to listen for
* `child_added`, `child_removed`, and `child_changed`
*/
_findAllAddEventListeners(store, typeClass, ref) {
var modelName = typeClass.modelName;
this._findAllMapForType[modelName] = true;
ref.on('child_added', Ember.run.bind(this, function (snapshot) {
if (!store.hasRecordForId(modelName, this._getKey(snapshot))) {
this._handleChildValue(store, typeClass, snapshot);
}
}));
},
/**
* Push a new child record into the store
*/
_handleChildValue(store, typeClass, snapshot) {
// No idea why we need this, we are already turning off the callback by
// calling ref.off in recordWillUnload. Something is fishy here
if (store.isDestroying) {
return;
}
var value = snapshot.val();
if (value === null) {
var id = this._getKey(snapshot);
var record = store.peekRecord(typeClass.modelName, id);
// TODO: refactor using ED
if (!record.get('isDeleted')) {
record.deleteRecord();
}
} else {
const payload = this._assignIdToPayload(snapshot);
this._pushLater(typeClass.modelName, payload.id, payload);
}
},
/**
* `createRecord` is an alias for `updateRecord` because calling \
* `ref.set()` would wipe out any existing relationships
*/
createRecord(store, typeClass, snapshot) {
return this.updateRecord(store, typeClass, snapshot).then(() => {
this.listenForChanges(store, typeClass, snapshot.record);
});
},
/**
* Called by the store when a record is created/updated via the `save`
* method on a model record instance.
*
* The `updateRecord` method serializes the record and performs an `update()`
* at the the Firebase location and a `.set()` at any relationship locations
* The method will return a promise which will be resolved when the data and
* any relationships have been successfully saved to Firebase.
*
* We take an optional record reference, in order for this method to be usable
* for saving nested records as well.
*/
updateRecord(store, typeClass, snapshot) {
var recordRef = this._getAbsoluteRef(snapshot.record);
var recordCache = this._getRecordCache(typeClass, snapshot.id);
var pathPieces = recordRef.path.toString().split('/');
var lastPiece = pathPieces[pathPieces.length-1];
var serializedRecord = snapshot.serialize({
includeId: (lastPiece !== snapshot.id) // record has no firebase `key` in path
});
const serializer = store.serializerFor(typeClass.modelName);
return new Promise((resolve, reject) => {
var relationshipsToSave = [];
// first we remove all relationships data from the serialized record, we backup the
// removed data so that we can save it at a later stage.
snapshot.record.eachRelationship((key, relationship) => {
const relationshipKey = serializer.keyForRelationship(key);
const data = serializedRecord[relationshipKey];
const isEmbedded = this.isRelationshipEmbedded(store, typeClass.modelName, relationship);
const hasMany = relationship.kind === 'hasMany';
if (hasMany || isEmbedded) {
if (!Ember.isNone(data)) {
relationshipsToSave.push({
data:data,
relationship:relationship,
isEmbedded:isEmbedded,
hasMany:hasMany
});
}
delete serializedRecord[relationshipKey];
}
});
var reportError = (errors) => {
var error = new Error(`Some errors were encountered while saving ${typeClass} ${snapshot.id}`);
error.errors = errors;
reject(error);
};
this._updateRecord(recordRef, serializedRecord).then(() => {
// and now we construct the list of promise to save relationships.
var savedRelationships = relationshipsToSave.map((relationshipToSave) => {
const data = relationshipToSave.data;
const relationship = relationshipToSave.relationship;
if (relationshipToSave.hasMany) {
return this._saveHasManyRelationship(store, typeClass, relationship, data, recordRef, recordCache);
} else {
// embedded belongsTo, we need to fill in the informations.
if (relationshipToSave.isEmbedded) {
return this._saveEmbeddedBelongsToRecord(store, typeClass, relationship, data, recordRef);
}
}
}
);
return Ember.RSVP.allSettled(savedRelationships);
}).catch((e) => {
reportError([e]);
}).then((results) => {
var rejected = Ember.A(results).filterBy('state', 'rejected');
if (rejected.length !== 0) {
reportError(rejected.mapBy('reason').toArray());
} else {
resolve();
}
});
}, `DS: FirebaseAdapter#updateRecord ${typeClass} to ${recordRef.toString()}`);
},
/**
* Update a single record without caring for the relationships
* @param {Firebase} recordRef
* @param {Object} serializedRecord
* @return {Promise}
*/
_updateRecord(recordRef, serializedRecord) {
this._incrementWaiters();
return toPromise(recordRef.update, recordRef, [serializedRecord])
.then((result) => {
this._decrementWaiters();
return result;
})
.catch((e) => {
this._decrementWaiters();
return Ember.RSVP.reject(e);
});
},
/**
* Call _saveHasManyRelationshipRecord on each record in the relationship
* and then resolve once they have all settled
*/
_saveHasManyRelationship(store, typeClass, relationship, ids, recordRef, recordCache) {
if (!Ember.isArray(ids)) {
throw new Error('hasMany relationships must must be an array');
}
var idsCache = Ember.A(recordCache[relationship.key]);
var dirtyRecords = [];
// Added
var addedRecords = ids.filter((id) => {
return !idsCache.includes(id);
});
// Dirty
dirtyRecords = ids.filter((id) => {
var relatedModelName = relationship.type;
return store.hasRecordForId(relatedModelName, id) && store.peekRecord(relatedModelName, id).get('hasDirtyAttributes') === true;
});
dirtyRecords = uniq(dirtyRecords.concat(addedRecords)).map((id) => {
return this._saveHasManyRecord(store, typeClass, relationship, recordRef, id);
});
// Removed
var removedRecords = idsCache.filter((id) => {
return !ids.includes(id);
});
removedRecords = removedRecords.map((id) => {
return this._removeHasManyRecord(store, recordRef, relationship.key, typeClass, id);
});
// Combine all the saved records
var savedRecords = dirtyRecords.concat(removedRecords);
// Wait for all the updates to finish
return Ember.RSVP.allSettled(savedRecords).then((savedRecords) => {
var rejected = Ember.A(Ember.A(savedRecords).filterBy('state', 'rejected'));
if (rejected.get('length') === 0) {
// Update the cache
recordCache[relationship.key] = ids;
return savedRecords;
}
else {
var error = new Error(`Some errors were encountered while saving a hasMany relationship ${relationship.parentType} -> ${relationship.type}`);
error.errors = Ember.A(rejected).mapBy('reason');
throw error;
}
});
},
/**
* If the relationship is `async: true`, create a child ref
* named with the record id and set the value to true
* If the relationship is `embedded: true`, create a child ref
* named with the record id and update the value to the serialized
* version of the record
*/
_saveHasManyRecord(store, typeClass, relationship, parentRef, id) {
const serializer = store.serializerFor(typeClass.modelName);
var ref = this._getRelationshipRef(parentRef, serializer.keyForRelationship(relationship.key), id);
var record = store.peekRecord(relationship.type, id);
var isEmbedded = this.isRelationshipEmbedded(store, typeClass.modelName, relationship);
if (isEmbedded) {
return record.save();
}
return toPromise(ref.set, ref, [true]);
},
/**
* Determine from the serializer if the relationship is embedded via the
* serializer's `attrs` hash.
*
* @return {Boolean} Is the relationship embedded?
*/
isRelationshipEmbedded(store, modelName, relationship) {
var serializer = store.serializerFor(modelName);
return serializer.hasDeserializeRecordsOption(relationship.key);
},
/**
* Determine from if the record is embedded via implicit relationships.
*
* @return {Boolean} Is the relationship embedded?
*/
isRecordEmbedded(record) {
if (record._internalModel) {
record = record._internalModel;
}
var found = this.getFirstEmbeddingParent(record);
return !!found;
},
/**
* Remove a relationship
*/
_removeHasManyRecord(store, parentRef, key, typeClass, id) {
const relationshipKey = store.serializerFor(typeClass.modelName).keyForRelationship(key);
var ref = this._getRelationshipRef(parentRef, relationshipKey, id);
return toPromise(ref.remove, ref, [], ref.toString());
},
/**
* Save an embedded belongsTo record and set its internal firebase ref
*
* @return {Promise<DS.Model>}
*/
_saveEmbeddedBelongsToRecord(store, typeClass, relationship, id, parentRef) {
var record = store.peekRecord(relationship.type, id);
if (record) {
return record.save();
}
return Ember.RSVP.Promise.reject(new Error(`Unable to find record with id ${id} from embedded relationship: ${JSON.stringify(relationship)}`));
},
/**
* Called by the store when a record is deleted.
*/
deleteRecord(store, typeClass, snapshot) {
var ref = this._getAbsoluteRef(snapshot.record);
ref.off('value');
return toPromise(ref.remove, ref);
},
/**
* Determines a path fo a given type
*/
pathForType(modelName) {
var camelized = Ember.String.camelize(modelName);
return pluralize(camelized);
},
/**
* Return a Firebase reference for a given modelName and optional ID.
*/
_getCollectionRef(typeClass, id) {
var ref = this._ref;
if (typeClass) {
ref = ref.child(this.pathForType(typeClass.modelName));
}
if (id) {
ref = ref.child(id);
}
return ref;
},
/**
* Returns a Firebase reference for a record taking into account if the record is embedded
*
* @param {DS.Model} record
* @return {Firebase}
*/
_getAbsoluteRef(record) {
if (record._internalModel) {
record = record._internalModel;
}
var embeddingParent = this.getFirstEmbeddingParent(record);
if (embeddingParent) {
var { record: parent, relationship } = embeddingParent;
const embeddedKey = parent.store.serializerFor(parent.modelName).keyForRelationship(relationship.key);
var recordRef = this._getAbsoluteRef(parent).child(embeddedKey);
if (relationship.kind === 'hasMany') {
recordRef = recordRef.child(record.id);
}
return recordRef;
}
return this._getCollectionRef(record.type, record.id);
},
/**
* Returns the parent record and relationship where any embedding is detected
*
* @param {DS.InternalModel} internalModel
* @return {Object}
*/
getFirstEmbeddingParent(internalModel) {
let relationships = assign(
{},
internalModel._implicitRelationships,
internalModel._relationships.initializedRelationships
);
let embeddingParentRel;
let relationshipKeys = Object.keys(relationships);
for (let i = 0; i < relationshipKeys.length; i++) {
let rel = relationships[relationshipKeys[i]];
let members = rel.members.toArray();
let parent = members[0];
if (!parent || !rel.inverseKey) {
continue;
}
let parentRel = parent._relationships.get(rel.inverseKey);
if (this.isRelationshipEmbedded(this.store, parent.type.modelName, parentRel.relationshipMeta)) {
embeddingParentRel = rel;
break;
}
}
if (embeddingParentRel) {
var parent = embeddingParentRel.members.toArray()[0];
var parentKey = embeddingParentRel.inverseKey;
var parentRel = parent._relationships.get(parentKey).relationshipMeta;
return { record: parent, relationship: parentRel };
}
},
/**
* Return a Firebase reference based on a relationship key and record id
*/
_getRelationshipRef(ref, key, id) {
return ref.child(key).child(id);
},
/**
* The amount of time (ms) before the _queue is flushed
*/
_queueFlushDelay: (1000/60), // 60fps
/**
* Schedules a `_flushQueue` for later.
*
* @private
*/
_flushLater() {
Ember.run.later(this, this._flushQueue, this._queueFlushDelay);
},
/**
* Flush all delayed `store.push` payloads in `this._queuedPayloads`.
*
* @private
*/
_flushQueue() {
const store = this.get('store');
if (store.isDestroying) {
return;
}
this._queue.forEach((key) => {
const { payload, modelName } = this._queuedPayloads[key];
const normalizedData = store.normalize(modelName, payload);
store.push(normalizedData);
});
this._queuedPayloads = {};
this._queue.length = 0;
},
/**
* Schedule a payload push for later. This will only push at most one payload
* per record. When trying to push to the same record multiple times, only the
* last push will be kept.
*
* @param {string} modelName
* @param {string} id
* @param {!Object<string, *>} payload
* @private
*/
_pushLater(modelName, id, payload) {
const store = this.get('store');
if (!this._queueFlushDelay) {
const normalizedData = store.normalize(modelName, payload);
store.push(normalizedData);
return;
}
const key = `${modelName}-${id}`;
if (this._queuedPayloads[key]) {
// remove from original place in queue (will be added to end)
const oldPosition = this._queue.indexOf(key);
this._queue.splice(oldPosition, 1);
}
this._queuedPayloads[key] = { payload, modelName };
this._queue.push(key);
// if this is the first item to be queued, schedule a flush
if (this._queue.length === 1) {
this._flushLater();
}
},
/**
* A cache of hasMany relationships that can be used to
* diff against new relationships when a model is saved
*/
_recordCacheForType: undefined,
/**
* _updateHasManyCacheForType
*/
_updateRecordCacheForType(typeClass, payload, store) {
if (!payload) { return; }
const id = payload.id;
const cache = this._getRecordCache(typeClass, id);
const serializer = store.serializerFor(typeClass.modelName);
// Only cache relationships for now
// and do the same for embedded records
typeClass.eachRelationship((key, relationship) => {
if (relationship.kind === 'hasMany') {
const relationshipPayload = payload[serializer.keyForRelationship(key)];
if (!relationshipPayload) {
cache[key] = Ember.A();
} else {
const isEmbedded = this.isRelationshipEmbedded(store, typeClass.modelName, relationship);
if (isEmbedded) {
const relationshipTypeClass = store.modelFor(relationship.type);
for (let id in relationshipPayload) {
let obj = relationshipPayload[id];
obj.id = id;
this._updateRecordCacheForType(relationshipTypeClass, obj, store);
}
} else {
const ids = Object.keys(relationshipPayload);
cache[key] = Ember.A(ids);
}
}
}
});
},
/**
* Get or create the cache for a record
*/
_getRecordCache(typeClass, id) {
var modelName = typeClass.modelName;
var cache = this._recordCacheForType;
cache[modelName] = cache[modelName] || {};
cache[modelName][id] = cache[modelName][id] || {};
return cache[modelName][id];
},
/**
* A utility for retrieving the key name of a Firebase ref or
* DataSnapshot. This is backwards-compatible with `name()`
* from Firebase 1.x.x and `key()` from Firebase 2.0.0+. Once
* support for Firebase 1.x.x is dropped in EmberFire, this
* helper can be removed.
*/
_getKey(refOrSnapshot) {
var key;
if (typeof refOrSnapshot.key === 'function') {
key = refOrSnapshot.key();
} else if (typeof refOrSnapshot.key === 'string') {
key = refOrSnapshot.key;
} else {
key = refOrSnapshot.name();
}
return key;
},
/**
* We don't need background reloading, because firebase!
*/
shouldBackgroundReloadRecord() {
return false;
}
});