Fireplace

Firebase addon for Ember.js

Introduction

Fireplace is an Ember.js addon for Firebase.

Build Status

Installation

Install as an Ember CLI addon:

npm install --save-dev fireplace

Then run the generator to install dependencies (Firebase from Bower):

ember generate fireplace

Getting Started

Setup your Firebase root path by extending the default Store.

// app/stores/main.js
import {Store} from 'fireplace';
export default Store.extend({
  firebaseRoot: "https://your-firebase.firebaseio.com"
});

Models & Attributes

Define a model by extending from Model and giving it some attributes:

// app/models/person.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr()
});

attr takes a type which tells it how to transform to and from Firebase, and options.

Fireplace supports the following types out of the box:

  • string (the default if no type is supplied)
  • boolean
  • date (iso8601 formatted date)
  • hash
  • number
  • timestamp (the epoch returned from date.getTime())

Custom Keys

You can change the key used to serialize the data to/from Firebase with the key option. By default this is the underscored version of the name, so firstName maps to first_name.

For example, lets add a date of birth date attribute and map the lastName attribute to “surname”:

// app/models/person.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr({key: "surname"}),
  dob: attr("date")
})

Default Values

Attributes can have default values, specify this with the default option which can be either the value itself or a function which returns the default value (good for dates etc…)

// app/models/person.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  firstName: attr({default: "John"}),
  lastName: attr({default: "Smith"}),
  createdAt: attr("date", {default: function() { return new Date(); }})
})

Saving

Now that we’ve defined a basic model, let’s save it to Firebase.

As with Ember Data, the Store is our access point into Fireplace and it’s injected into all controllers and routes.

We create an instance of a model with store.createRecord and then save it with model.save():

var person = this.store.createRecord("person", {
  firstName: "Bob",
  lastName: "Johnson"
});

person.save();

Now the model is saved, it’s now live and any changes which occur in Firebase are reflected in the local instance.

Saving a model returns a promise, so we can wait for Firebase to confirm that the changes are saved like so:

person.save().then(function(){
  // do something here, like transitioning to a route etc...
});

Model#save is a shortcut to Store#saveRecord so if you prefer to type more then the above example could be re-written as:

var person = this.store.createRecord("person", {
  firstName: "Bob",
  lastName: "Johnson"
});

this.store.saveRecord(person);

Deleting

Simply call delete on a model to delete it.

As with saving, we return a promise so you can wait for Firebase to confirm that the save succeeded.

Likewise with saving, Model#delete is syntactic sugar for Store#deleteRecord.

Finding

We’ve now saved some data to Firebase, lets go get it back out again.

All examples below use store.fetch as that returns a promise which works with Ember’s router. You can use store.find instead to immediately return a model / collection.

Finding individual records

Let’s say we’ve got a person saved with an ID of “123”, we can fetch that with:

this.store.fetch("person", "123");

Once resolved, the record will be live and update itself whenever Firebase updates.

If more data is required to build the Firebase reference (see customising references later) then we can provide them as a 3rd argument:

Eg if a list of tasks is at /projects/some-project/tasks/123 then we might do something like:

this.store.fetch("task", "123", {project: someProject});

Finding lists of records

To find every item in a list:

this.store.fetch("person")

To limit to a number of records, or provide a start/end point then we can supply an options object:

this.store.fetch("person", {limit: 10, startAt: "123", endAt: "456"})

If we need to provide more data to build the Firebase reference, then we provide them before the limit options:

this.store.fetch("task", {project: someProject}, {limit: 10, startAt: "123", endAt: "456"})

Relationships

hasOne

// app/models/person.js
import {Model, attr, hasOne} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr()
  address: hasOne()
});

// app/models/address.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  street: attr(),
  city: attr(),
  postcode: attr()
});

This maps to the Firebase JSON of:

{
  first_name: "John",
  last_name: "Watson",
  address: {
    street: "221B Baker Street",
    city: "London",
    postcode: "NW1 6XE"
  }
}

By default hasOne guesses the name of the associated type based on the name of the property, in this case address.

If you want to call the property something different to the model type, pass its name as the first argument:

// app/models/person.js
import {Model, hasOne} from 'fireplace';
export default Model.extend({
  residence: hasOne("address")
});

Firebase stores data in a tree structure, so Fireplace by default treats all relationships as embedded. We can set the embedded: false option to change this:

// app/models/person.js
import {Model, attr, hasOne} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr(),
  address: hasOne({embedded: false})
});

and now the JSON is:

{
  first_name: "John",
  last_name: "Watson",
  address: 123
}

This assumes that the address is stored at /addresses/123 where 123 is the ID of the address.

We’ll cover configuring the path of the item in Firebase later.

hasMany

Lets say our person lives in many different places, we can model this like so:

// app/models/person.js
import {Model, attr, hasMany} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr(),
  addresses: hasMany()
});

// app/models/address.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  street: attr(),
  city: attr(),
  postcode: attr()
});

The JSON for this is now:

{
  first_name: "The",
  last_name: "Queen",
  addresses: {
    123: {
      street: "Buckingham Palace",
      city: "London",
      postcode: "SW1A 1AA"
    },
    456: {
      street: "Windsor Castle",
      city: "London",
      postcode: "SL4 1NJ"
    }
  }
}

Like hasOne, hasMany guesses the name of the associated type based on the singular name of the property, in this case addresses -> address.

If you want to call the property something different to the model type, pass its name as the first argument:

// app/models/person.js
import {hasOne} from 'fireplace';
export default Model.extend({
  residences: hasOne("address")
});

Again, we can change this to non-embedded by setting {embedded: false} to produce:

{
  first_name: "The",
  last_name: "Queen",
  addresses: {
    123: true,
    456: true
  }
}

Storing additional data with a non-embedded relationship

By default the relationships are stored as {id: true}, but we can store information there too.

Lets say we have projects which have people as members and each member has an access level.

Because it’s a hasMany relationship, we can’t store the meta information for the relationship on the model itself because that person object can belong to many different projects.

Instead we use a MetaModel which lets us store the information for this particular member.

// app/models/project.js
import {Model, attr, hasMany} from 'fireplace';
export default Model.extend({
  title: attr(),
  members: hasMany("people", {embedded: false, as: "member"})
});

// app/models/member.js
import {MetaModel} from 'fireplace';
export default MetaModel.extend();

The JSON for this would now be something like:

{
  title: "A project",
  members: {
    123: "admin",
    234: "member",
    345: "admin"
  }
}

The meta value is available on the meta model as meta:

var member = project.get("firstObject");
member.get("meta"); => "admin"

To change this to something more descriptive, you can use Ember.computed.alias:

// app/models/member.js
import Ember from 'ember';
import {MetaModel} from 'fireplace';
export default MetaModel.extend({
  accessLevel: Ember.computed.alias("meta")
});

If you want to store more complex data on a relationship, you can give the MetaModel attributes and relationships just like a normal model. All the same rules apply:

// app/models/member.js
import {MetaModel, attr} from 'fireplace';
export default MetaModel.extend({
  accessLevel: attr(),
  joinedAt: attr("date")
});

This would produce JSON like so:

{
  title: "A project",
  members: {
    123: {
      access_level: "admin",
      joined_at: "2012-11-24T15:00:00"
    },
    234: {
      access_level: "member",
      joined_at: "2012-12-11T14:30:00"
    }
  }
}

Keep in mind that, when using MetaModels, you have to set the actual model as the content property on the MetaModel and then add the MetaModel to the parent’s collection like in this sample:

// project and person (of class People) are loaded already
var member = store.createRecord('member', {
  accessLevel: "admin",
  joinedAt: new Date(),
  content: person // the actual "content" of this relationship
});

project.get('members').addObject(member);
project.save();

When loading projects and getting members, the MetaModel’s properties are available on the real member (instance of People) as if they were a part of it.

Detached relationships

All the above examples assume that the associated object itself or its ID is stored with the parent, but what if you want to store something completely separately? Here we can use detached relationships.

For example, lets say we stored people with their avatars completely separately in the tree because we’re storing the image data and we don’t want to include that by default when we fetch a list of people. We don’t store the avatar ID with the person because maybe every person has an avatar, so the JSON’s something like:

{
  people: {
    123: {
      name: "John Smith"
    }
  },
  avatars: {
    123: {
      filename: "an-image.png",
      data: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="
    }
  }
}

We can model this like so:

// app/models/person.js
import {Model, attr, hasOne} from 'fireplace';
export default Model.extend({
  name: attr(),
  avatar: hasOne({detached: true})
});

By default this then looks for the avatar at /avatars/, we’ll look at how to change that later should you want to store things in a different place.

Detached hasMany relationships are specified in a similar way, say a task can be assigned to multiple people, but we want to be able to list them for a specific person. We could set this up in Firebase like so:

{
  people: {
    123: {
      name: "Tom Ford"
    },
    234: {
      name: "Paul Smith"
    },
  },
  tasks_by_person: {
    123: {
      345: true,
      456: true
    },
    234: {
      345: true
    }
  },
  tasks: {
    345: {
      title: "A task assigned to both people",
      assignees: {
        123: true,
        234: true
      }
    },
    456: {
      title: "A task assigned to one person",
      assignees: {
        123: true
      }
    }
  }
}

Here we’ve got a list of people, a list of tasks and an index which maps each person to their tasks.

We can model this like so:

// app/models/task.js
import {Model, attr, hasMany} from 'fireplace';
export default Model.extend({
  title: attr(),
  assignees: hasMany("people")
});

// app/models/person.js
import {Model, attr, hasMany} from 'fireplace';
export default Model.extend({
  name: attr(),
  tasks: hasMany({detached: true, path: "tasks_by_person/"})
});

If the given path is a string, as it is here, it’s expanded and appended to the root Firebase path.

For complete control over the path you can provide a function and return either a string or a Firebase reference:

// app/models/person.js
import {Model, hasMany} from 'fireplace';
export default Model.extend({
  tasks: hasMany({
    detached: true,
    path: function() {
      return this.get("project").buildFirebaseReference().
        child("tasks/by-person").
        child(this.get("id"));
    }
  })
});

A detached hasMany is assumed to be an indexed collection, as opposed to a collection of the items itself.

Priorities

If a model has a priority property, then that’s used when saving to Firebase.

For example, lets say we want to order all people by their full name, last name first:

// app/models/person.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr(),
  priority: function(){
    return [this.get("firstName"), this.get("lastName")].join(" ").toLowerCase();
  }.property("firstName", "lastName")
})

Note that the priority here is a normal Ember property and not an attr. That’s because we’re not storing it as an attribute in the JSON.

The same applies to a MetaModel so you can order items in an indexed list.

For example, lets order a list of members by the date they were added to a project:

// app/models/person.js
import {Model, attr} from 'fireplace';
export default Model.extend({
  firstName: attr(),
  lastName: attr()
})

// app/models/project.js
import {Model, attr, hasMany} from 'fireplace';
export default Model.extend({
  title: attr(),
  members: hasMany("people", {as: "member"})
})

// app/models/member.js
import {MetaModel, attr} from 'fireplace';
export default MetaModel.extend({
  createdAt: attr("date"),

  priority: function(){
    return this.get("createdAt").toISOString();
  }.property("createdAt")
})

Custom Paths

By default Fireplace assumes that non-embedded records will be stored at the root of your store’s Firebase reference with each model type stored under its pluralized underscored name.

Embedded records are stored relative to their parent records.

For example, each App.Person is stored at /people/id.

To customise this you can override the firebasePath property on the model’s class.

Let’s change App.Person to store its data at /member/profiles instead of /people:

// app/models/person.js
import {Model} from 'fireplace';
var Person = Model.extend();
export default Person;

Person.reopenClass({
  firebasePath: "member/profiles"
});

Any handlebars style parameters will be expanded, so lets say we store each person under the project they are a member of, eg /projects/123/people/456, then we can provide a path like so:

Person.reopenClass({
  firebasePath: "projects//people"
});

In this case, whenever we look for a person we’ll need to provide the project and the model should have a project property so that it knows where it is to be saved to.

this.store.fetch("person", {project: someProject});

Finally, for complete control you can specify a function, the equivalent to the template string above would be:

Person.reopenClass({
  firebasePath: function(options) {
    var projectID = options.get("project.id");
    return "projects/"+projectID+"/people";
  }
})

Custom Collections

There are two types of collection built in:

  • ObjectCollection - returned from finders and hasMany embedded relationships
  • IndexedCollection - returned from hasMany non-embedded / detached relationships

You can create a custom collection by extending either of these as a starting point.

For example, lets make a collection of objects which sets a random priority when items are added:

// app/collections/random.js
import {ObjectCollection} from 'fireplace';
export default ObjectCollection.extend({

  replaceContent: function(idx, numRemoved, objectsAdded) {
    var priority;
    objectsAdded.forEach(function(obj, i) {
      if (!obj.get("priority")) {
        priority = Math.random();
        obj.set("priority", priority);
      }
    });
    return this._super(idx, numRemoved, objectsAdded);
  }

});

We can now use this in queries:

this.store.fetch("person", {collection: "random"})

and in relationships:

// app/models/person.js
import {Model} from 'fireplace';
export default Model.extend({
  tasks: hasMany({collection: "random"})
});

Forking

You can fork the store to make changes which don’t affect any existing data until you save.

For example, if you have an editing interface and you don’t want changes to affect parts of the page which are being displayed with that record’s details then you can fork the store and get a new copy of the record which is totally isolated:

var person = this.store.find("person", 1);
var newStore = this.store.fork();
var personClone = newStore.find("person", 1);

When you save personClone then person will also update, but not until then.

We fork the store instead of just copying the record because this makes sure we have a completely fresh cache & that all embedded records are also isolated.