Skip to content

Instantly share code, notes, and snippets.

@elgordino
Last active June 4, 2017 12:43
Show Gist options
  • Save elgordino/fe15fd83e8aed0351045a5144a7ec1a2 to your computer and use it in GitHub Desktop.
Save elgordino/fe15fd83e8aed0351045a5144a7ec1a2 to your computer and use it in GitHub Desktop.
An ember service to manage emberfire relationships and record values atomically.

Intro

This ember service provides a way to make attomic changes to relationships and record values in Firebase. This is useful when you have relationships that are not simple parent/child relationships or need to updated fields across multiple records in an atomic transaction.

This code has been copied directly from my project and is intended to be used as an example rather than being cut/pasted directly into your project! :)

Important

Please note this code is untested and usage is at your own risk!

Related models must have an inverse relationship to be linked, or unlinked.

Usage with embedded records has not been tested and behavior is undefined!

As written the service relies on ember-debug-logger plugin, remove/adapt the this.debug lines if you don't have that.

The service also uses async/await so you will probably need Babel transpilation.

Usage

Include the service like any ember service in your route/controller/component:

relationships: Ember.inject.service()

There are several methods to manage your records. They are all async methods.

LinkRecord / UnlinkRecord

await get(this,'relationships').linkRecords(recordA, recordsB);
await get(this,'relationships').unlinkRecords(recordA, recordsB);

The service will examine the models for recordA and recordB to determine the keys and type of the relationship and then make appropriate updates in a single Firebase update.

DeleteRecord

await get(this, 'relationships').deleteRecord(record);

This service will examine the relationships in the model for record and for each relationship that has an inverse it will update the related record to remove the relationship and it will remove record entirely in a single Firebase update.

Transactions

If you need to roll more than one update into a single atomic transaction then you can wrap a transaction around multiple updates like so:

let relationships = get(this, 'relationships');
let transaction = relationships.startTransaction()
await relationships.deleteRecord(recordX, transaction);
await relationships.unlinkRecords(recordA, recordsB, transaction);
await relationships.linkRecords(recordA, recordsC, transaction);
await relationships.commitTransaction(transaction);

Note that the calls to delete, link and unlink take the additional 'transaction' parameter, which is returned by startTransaction.

SetRecordValue

Additionally to changing relationships you can update any values on any records as part of the same transaction.

await get(this,'relationships').setRecordValue(record, key, value, transaction)

For example:

let relationships = get(this, 'relationships');
let transaction = relationships.startTransaction()
await relationships.setRecordValue(recordA, 'status', 'saved', transaction)
await relationships.setRecordValue(recordB, 'status', 'updated', transaction)
await relationships.deleteRecord(recordX, transaction);
await relationships.commitTransaction(transaction);

Testing

If you would like to perform a dry run then comment out the db.ref.update in _performUpdates;

/* Copyright (C) Lineup Ninja Ltd - All Rights Reserved
* Unauthorized copying of this file, via any medium is strictly prohibited
* Proprietary and confidential
* Written by Gordon Johnston <gord@lineup.ninja>, April 2017
*/
import Ember from 'ember';
import { task } from 'ember-concurrency';
const { Service, get, set, RSVP: { Promise }, inject: { service }, assert, getOwner, guidFor } = Ember;
export default Service.extend({
firebaseApp: service(),
store: service(),
_transactions: {},
/**
* Starts a new transaction. Use the returned ID with calls to linkRecords etc for their updates to be placed into the transaction
* @param {string} transactionId A unique identifier to use for the transaction, optional
* @returns {string} The id for the transaction, you must use this ID for commitTransaction
*/
startTransaction(transactionId = null) {
let transaction = {
updates: {}
};
if (transactionId === null) {
transactionId = guidFor(transaction);
}
set(this, `_transactions.${transactionId}`, transaction);
this.debug(`created transaction ${transactionId}`);
return transactionId;
},
/**
* Commits the requested transactionId into firebase
* @param {string} transactionId
*/
async commitTransaction(transactionId) {
this.debug(`committing transaction ${transactionId}`);
let updates = get(this, `_transactions.${transactionId}.updates`);
assert(`Transaction ID ${transactionId} not found.`, typeof updates === 'object');
await this._performUpdates(updates);
delete get(this, '_transactions').transactionId;
this.debug(`transaction performed`);
},
/**
* Cancels the requested transactionId
* @param {string} transactionId
*/
cancelTransaction(transactionId) {
this.debug(`cancelling transaction ${transactionId}`);
delete get(this, '_transactions').transactionId;
},
/**
* Cancels all pending transactions
*/
cancelAllTransactions() {
this.debug('cancelling all transactions');
set(this, '_transactions', {});
},
/**
* Link two records, there must be a relationship with an inverse between the two records
* @param {DS.Model} recordA
* @param {DS.Model} recordB
* @param {string} transactionId optional transaction id
*/
async linkRecords(recordA, recordB, transactionId = null) {
return this._relateRecords(recordA, recordB, true, transactionId);
},
/**
* Unlink two records, there must be a relationship with an inverse between the two records
* It should be safe to call this even if the records are not currently associated, it will just set null values for paths that don't exist anyway
* @param {DS.Model} recordA
* @param {DS.Model} recordB
* @param {string} transactionId optional transaction id
*/
async unlinkRecords(recordA, recordB, transactionId = null) {
return this._relateRecords(recordA, recordB, false, transactionId);
},
/**
* Deletes a record and any updates other records that have an inverse relationship
* @param {DS.Model} record An ember data record
* @param {string} transactionId optional transaction id
*/
async deleteRecord(record, transactionId = null) {
let relationships = await this._getRelationshipsByName(record, true);
let updateRefConfig = {
sourceRecord: record,
relationships: relationships,
link: false,
updateSource: false
};
let updates = this._getUpdateRefs(updateRefConfig);
// Also remove the path for the record itself
let recordRef = record.ref();
let recordPath = this._pathForRef(recordRef);
updates[recordPath] = null;
/* Mark the record as deleted before removing it in Firebase.
This prevents exception where a request for the object may
be made to Firebase after the record has been removed which
may fail depending on your Firebase rules
*/
get(this, 'store').deleteRecord(record);
await this._performUpdatesWithTransaction(updates, transactionId);
},
/**
* Updates a key on a record. Use in a transaction to roll multiple changes into a single update
*
* Note that the key will have '.' replaced with '/' to convert to the firebase path
*
* @param {DS.model} record An ember data record
* @param {string} key The key on the model to use
* @param {*} value The value to set
* @param {string} transactionId optional transaction id - If you're not using transactions you're probably better off just updating the record directly
*/
async setRecordValue(record, key, value, transactionId = null) {
let recordRef = record.ref();
let recordPath = this._pathForRef(recordRef);
let keyPath = key.replace('.', '/');
let updatePath = `${recordPath}/${keyPath}`;
let update = {
[updatePath]: value
};
await this._performUpdatesWithTransaction(update, transactionId);
},
/**
* An ember concurrency task that is used by the warning dialog to perform the delete
* @param {DS.Model} record The record to delete
* @param {string} transactionId transaction id - can be null
*/
_confirmDeleteRecord: task(function* (record, transactionId) {
yield this.deleteRecord(record, transactionId);
let applicationModal = this._applicationModal();
get(applicationModal, 'hide').perform();
}).drop(),
/**
* An ember concurrency task that is used by the warning dialog to cancel the delete
*/
_cancelDeleteRecord: task(function* () {
let applicationModal = this._applicationModal();
get(applicationModal, 'hide').perform();
}).drop(),
/**
* Returns the application modal
*/
_applicationModal() {
let owner = getOwner(this);
return owner.lookup('modal:application-modal');
},
/**
* Links or unlinks two records
* @param {DS.Model} recordA
* @param {DS.Model} recordB
* @param {boolean} link - True to link the records, false to remove the link
* @param {string} transactionId transaction id - can be null
*/
async _relateRecords(recordA, recordB, link, transactionId) {
let relationships = await this._getRelationshipsByName(recordA, false);
/* Within relationships we need to find a relationship to a model of the type of recordB that also has an inverse
Then push recordB onto the (empty) relatedRecords array for the relationship
*/
let foundRelationship = false;
let recordAModelName = recordA.constructor.modelName;
let recordBModelName = recordB.constructor.modelName;
this.debug(`Linking ${recordAModelName} to ${recordBModelName}`);
relationships.forEach(relationship => {
if (relationship.type === recordBModelName && relationship.inverse) {
this.debug(`Found relationship: ${recordAModelName}.${relationship.key}:${relationship.kind} <-> ${relationship.inverse.kind}:${recordBModelName}.${relationship.inverse.name}`);
relationship.relatedRecords.pushObject(recordB);
foundRelationship = true;
}
});
assert(`Did not find a relationship with an inverse between ${recordA.constructor.modelName} and ${recordB.constructor.modelName}`, foundRelationship);
let updateRefConfig = {
sourceRecord: recordA,
relationships: relationships,
link: link,
updateSource: true
};
let updates = this._getUpdateRefs(updateRefConfig);
await this._performUpdatesWithTransaction(updates, transactionId);
},
/**
* Given the specified configuration returns the updates required
* @param {object} config
* @param {DS.Model} config.sourceRecord the source record
* @param {result of _relatedRecords} config.relationships the relationships
* @param {boolean} config.link true if linking, false otherwise
* @param {boolean} config.updateSource if true the source record will have its relationship updated, if false it will not
* There is no need to delete the source relationship if the record is being deleted anyway
* @returns {object} Keys are firebase paths, values are the values to set, this is appropriate for passing to a firebase 'update'
*/
_getUpdateRefs(config) {
let sourceRecord = config.sourceRecord;
let relationships = config.relationships;
let updates = {};
relationships.forEach(relationship => {
let relatedRecords = relationship.relatedRecords;
for (let relatedRecord of relatedRecords) {
let relatedRef = relatedRecord.ref();
let relatedPath = this._pathForRef(relatedRef);
let inverse = get(relationship, 'inverse');
if (inverse.kind === 'belongsTo') {
let keyPath = `${relatedPath}/${inverse.name}`;
if (config.link) {
updates[keyPath] = sourceRecord.id;
} else {
updates[keyPath] = null;
}
} else if (inverse.kind === 'hasMany') {
let keyPath = `${relatedPath}/${inverse.name}/${sourceRecord.id}`;
if (config.link) {
updates[keyPath] = true;
} else {
updates[keyPath] = null;
}
} else {
assert(`Unkown inverse relationship type found: [${inverse.kind}] expected 'hasMany' or 'belongsTo'`);
}
if (config.updateSource) {
// Add the updates required to update the relationships on the source record
let sourceRef = sourceRecord.ref();
let sourcePath = this._pathForRef(sourceRef);
if (relationship.kind === 'belongsTo') {
let keyPath = `${sourcePath}/${relationship.key}`;
if (config.link) {
updates[keyPath] = relatedRecord.id;
} else {
updates[keyPath] = null;
}
} else if (relationship.kind === 'hasMany') {
let keyPath = `${sourcePath}/${relationship.key}/${relatedRecord.id}`;
if (config.link) {
updates[keyPath] = true;
} else {
updates[keyPath] = null;
}
} else {
assert(`Unkown relationship type found: [${relationship.kind}] expected 'hasMany' or 'belongsTo'`);
}
}
}
});
return updates;
},
/**
* Returns the relationshipsByName for the model of the record with additional properties per name
* - relatedRecords - The records that have an inverse relationship to this record (optionally)
* - inverse - The details of the inverse relationship
* @private
* @param {DS.Model} record
* @param {DS.Model} includeRecords - Whether to include the records
*/
async _getRelationshipsByName(record, includeRecords) {
let store = record.store;
// relationships is an Ember.Map
let relationships = get(record, 'constructor.relationshipsByName');
/* Add the keys for relationships to an array that we can map over
The Ember.Map does not have a 'map' method.
*/
let relationshipKeys = [];
relationships.forEach(relationship => relationshipKeys.pushObject(relationship.key));
await Promise.all(relationshipKeys.map(async key => {
/* eslint-disable ember/use-ember-get-and-set */
// Ember.get does not work on Ember.Map object (for some reason!, or I am doing it wrong)
let relationship = relationships.get(key);
/* eslint-enable ember/use-ember-get-and-set */
/*
Relationship has
key: the property name, eg 'createdBy'
kind: the type of relationship, eg 'belongsTo',
name: A human readable version of kind eg 'Belongs To',
type: the type it is related to, eg 'user'
options: The options configured ont the relationship
*/
let relatedRecords = [];
// Check to see if the relationship has an inverse, if not we don't need to do anything with the related records
let inverse = store.modelFor(record.constructor.modelName).inverseFor(key, store); // { type: App.Message, name: 'owner', kind: 'belongsTo' }
this.debug(`inverse is ${inverse}`);
if (inverse && includeRecords) {
/*
Inverse has
name: The name of the key on the other side of the relationship, eg 'locations'
kind: The type of relatipnship, eg 'hasMany'
*/
if (relationship.kind === 'belongsTo') {
let id = get(record, `${key}.id`);
if (id) {
this.debug(`model has active relationship: ${id}`);
let relatedRecord = await (get(record, key));
relatedRecords.pushObject(relatedRecord);
this.debug(`belongsTo: key: ${key} target: ${id}`);
}
} else if (relationship.kind === 'hasMany') {
await Promise.all(get(record, key).map(async relatedRecord => {
relatedRecords.pushObject(await relatedRecord);
}));
} else {
assert(`Unkown relationship type found: [${relationship.kind}] expected 'hasMany' or 'belongsTo'`);
}
this.debug(`Found and loaded ${relatedRecords.length} related records for key: ${key}`);
}
set(relationship, 'relatedRecords', relatedRecords);
set(relationship, 'inverse', inverse);
}));
return relationships;
},
/**
* Either adds the updates to the transaction or applies them immediately if transaction is null
* @param {object} updates - A hash of keys and values to push to firebase
* @param {string} transactionId - A transaction id. If null the updates will be applied immediately
*/
async _performUpdatesWithTransaction(updates, transactionId) {
if (transactionId === null) {
await this._performUpdates(updates);
} else {
let transactionUpdates = get(this, `_transactions.${transactionId}.updates`);
assert(`Transaction ID ${transactionId} not found.`, typeof transactionUpdates === 'object');
Object.assign(transactionUpdates, updates);
set(this, `_transactions.${transactionId}.updates`, transactionUpdates);
this.debug('added updates to transaction');
}
},
/**
* Applies the requested updates to firebase
* @param {object} updates - A hash of keys and values to push to firebase
*/
async _performUpdates(updates) {
this.debug(updates);
let db = get(this, 'firebaseApp').database();
await db.ref('/').update(updates);
this.debug('applied updates in firebase');
},
/**
* Returns the path to a ref as a string
* @param {firebase.database.Reference} ref
* @returns {string} Path to the reference
*/
_pathForRef(ref) {
let path = ref.key;
let parent = ref.parent;
while (parent.key) {
path = `${parent.key}/${path}`;
parent = parent.parent;
}
return path;
},
_dataForConfirmationModal() {
// Take care here, 'this' will be in the context of the modal
// As such warnings, confirmTask and record must be set on the modal itself
return {
display: false,
model: get(this, 'record'),
title: 'Warning!',
layout: 'vertical',
formElements: [
{
controlType: 'component',
component: 'warning/record-delete',
options: get(this, 'warnings')
}
],
submitButtonId: 'confirm',
buttons: [
{
id: 'cancel',
text: {
default: 'Cancel',
active: 'Please Wait'
},
type: 'secondary',
handler: {
type: 'task',
action: get(this, 'cancelTask'),
arguments: [],
returnsPromise: true
},
state: 'enabled'
},
{
id: 'confirm',
text: {
default: 'Confirm',
active: 'Please Wait'
},
type: 'danger',
handler: {
type: 'task',
action: get(this, 'confirmTask'),
arguments: [get(this, 'record'), get(this, 'transactionId')],
returnsPromise: true
},
state: 'enabled'
}
]
};
}
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment