Firebase Firestorm for Typescript
Firestorm is an ORM for firestore which can be used with Typescript.
This library currently only supports the client Firebase SDK.
Contents
Requirements
Firestorm relies on using Typescript's
experimental decorators
for defining your models. Please ensure you have the following in your tsconfig.json
(ES5 is minimum target):
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
}
}
Installation
$ npm install firebase-firestorm
Usage
Getting Started
In this section, we will walk you through an example of how a basic blogging database might look using posts, comments and authors.
1. Initialize firestorm
Call firestorm.initialize(firestore, options?)
as soon as you initialize
your firestore app. See intialization options
for more information about intiailizing firestorm.
import * as firestorm from 'firebase-firestorm';
...
const firestore = firebase.initializeApp(...).firestore();
firestorm.initialize(firestore, /* options */);
...
2. Defining root collections
Here we have a class representing a posts
collection. Entity classes are
typically non-pluralized as they represent a single document from that
collection. To define a root collection you must:
- Extend from the
Entity
class. - Annotate your class with
@rootCollection(opts: ICollectionConfig)
. - Declare a series of fields, annotated with
@field(opts: IFieldConfig)
.
import { Entity, rootCollection, field } from 'firebase-firestorm';
@rootCollection({
name: 'posts',
})
export default class Post extends Entity {
@field({ name: 'title' })
title!: string;
@field({ name: 'content' })
content!: string;
}
3. Defining subcollections
Each of your models, whether they represent a root collection or subcollection must extend from the
Entity
class provided.
Now we want documents in the posts
collection to have a subcollection
of comments
. First, we need to create a class for the comments. Notice
how we do not annotate the class with @rootCollection
.
import { Entity, rootCollection, field } from 'firebase-firestorm';
export default class Comment extends Entity {
@field({ name: 'content' })
content!: string;
@field({ name: 'by' })
by!: string;
}
Back in the Post
class, we can add Comment
as a subcollection using the @subCollection(opts: ISubcollectionConfig)
decorator.
import { Entity, ICollection, rootCollection, field } from 'firebase-firestorm';
import Comment from './Comment';
@rootCollection({
name: 'posts',
})
export default class Post extends Entity {
@subCollection({
name: 'comments',
entity: Comment, // we must define the entity class due to limitations in Typescript's reflection capabilities. Progress should be made on this issue in future releases.
})
comments!: ICollection<Comment>;
...
}
4. Defining document references
Finally we want documents in the posts
collection to reference an author in
an authors
collection (another root collection). First, we define the Author
entity:
import { Entity, rootCollection, field } from 'firebase-firestorm';
@rootCollection({
name: 'authors',
})
export default class Author extends Entity {
@field({ name: 'name' })
name!: string;
}
Then we can add an Author
reference to the Post
entity using the @documentRef(opts: IDocumentRefConfig)
decorator:
import { Entity, ICollection, IDocumentRef, rootCollection, field } from 'firebase-firestorm';
import Author from './Author';
@rootCollection({
name: 'posts',
})
export default class Post extends Entity {
@documentRef({
name: 'author',
entity: Author, // we must define the entity class due to limitations in Typescript's reflection capabilities. Progress should be made on this issue in future releases.
})
author!: IDocumentRef<Author>;
...
}
5. Querying/updating data
Now we've built our model, we're ready to start querying. Calling
Collection(entity : IEntity)
will return a list of methods use can
use to manipulate the data.
Getting a document
const post = Collection(Post).get('post-1').then((post : Post) => {
console.log(post);
});
Getting a subcollection
In our example Comment
is a subcollection of Post
. You can get
subcollections from a retrieved document, or a document reference.
// Comment subcollection from document.
const post = Collection(Post).get('post-1').then((post : Post) => {
const commentCollection = post.collection(Comment);
});
// Comment subcollection from document ref.
const postRef = Collection(Post).doc('post-1');
const commentCollection = postRef.collection(Comment);
// Finds all comments from commentCollection.
const commentsSnap = await commentCollection.find();
Querying data
Calling query()
on a collection will allow you to build queries in a similar fashion to the standard Firestore SDK. You can build a query by chaining together methods, and finally calling the get()
method to fetch the result. Omitting filters after the query()
method will return all results from a collection.
// Build the query.
const query = Collection(Post)
.query()
.where('title', '==', 'Example Title');
// Fetch and manipulate the result.
query.get().then((snapshot) => {
const post = snapshot.docs;
...
});
Creating documents
const post = new Post();
post.id = 'post-1'; // id is optional, if it is not defined it will be generated by firestore.
post.title = 'Untitled';
let savedPost : Post;
Collection(Post).create(post).then((_savedPost : Post) => {
savedPost = _savedPost;
});
Updating documents
const post = new Post();
post.id = 'post-1'; // id is required.
post.title = 'Untitled';
let savedPost : Post;
Collection(Post).update(post).then((_savedPost: Post) => {
savedPost = _savedPost;
});
Removing documents
Collection(Post).remove('post-id').then(...);
5. Realtime Updates
You can set up listeners for changes on either a single document, or a group of documents for a query. This is done a similar way to the standard Firebase SDK.
Listening to document updates
You can attach a listener to a single document reference by using the onSnapshot(callback)
method.
Collection(Post).doc('post-id').onSnapshot(
(snapshot): DocumentSnapshot<Post> => {
const post: Post = snapshot.doc;
}
);
...
The callback function will executed once with the initial snapshot payload, and then for any subsequent updates to that document.
Listening to a collection (or query)
You can attach a listener to a group of documents in a collection by using the onSnapshot(callback)
method on a collection query.
Collection(Post).query().onSnapshot(
(snapshot): QuerySnapshot<Post> => {
const posts: Post[] = snapshot.docs;
}
);
// or
Collection(Post)
.query()
.where('title', '==', 'Example Title')
.onSnapshot(
(snapshot): QuerySnapshot<Post> => {
const posts: Post[] = snapshot.docs;
}
);
The callback function will executed once with the initial snapshot payload, and then for any subsequent updates to that query. As per the Firebase SDK, you call see the document changes in each snapshot using the snapshot.docChanges()
method.
6. Formatting data
An instance of entity maybe contain properties such as
subcollections which you do not wish to include if, for example,
you are building a REST API. Calling the toData()
method on
an instance of an entity will produce a plain JSON object
containing just primitive data, nested JSON objects, and
document reference which have already been retrieved using
the .get()
method. For example:
import { Collection } from 'firebase-firestorm';
import Author from './Author';
import Post from './Post';
Collection(Post).get('post-1').then((post: Post) => {
console.log(post.toData());
/*
Output:
{
id: ...,
title: ...,
content: ...
}
*/
post.author.get().then((author: Author) => {
console.log(post.toData());
/*
Output:
{
id: ...,
title: ...,
content: ...,
author: {
id: ...,
name: ...
}
}
*/
});
});
Custom Data Types
Arrays
Firestore documents can contain arrays of strings, numbers, objects,
etc. Defining arrays in Firestorm is as simple as assigning properties
as array types in your Entity
files. For example:
class Example extends Entity {
@field({ name: 'example_property_1' })
property1!: string[];
@field({ name: 'example_property_2' })
property2!: IDocumentRef<AnotherEntity>[];
}
Nested Data
Firestore documents can contains nested objects (or maps). For a nested
object, you need to create a new class to represent that object, and add
a property with that class in your Entity
, wrapped with the @map
decorator.
class Example extends Entity {
@map({ name: 'nested_object' })
nestedObject!: Nested;
}
class Nested {
@field({ name: 'nested_property' })
nestedProperty!: string;
}
And then to use this entity:
const nested = new Nested();
nested.nestedProperty = 'test';
const example = new Example();
example.nestedObject = nested;
Important: If your is nested data is an array you must provide the 'entity' option in the configuration.
class Nested {
@map({ name: 'nested_array', entity: Nested })
nestedObject: Nested[];
}
Geopoints
Geopoints store locational data and can be used as fields. We have a wrapper class for firestore's GeoPoint which basically serves the same functionality.
class Example extends Entity {
@geoPoint({
name: 'geopoint_property',
})
geopoint!: IGeoPoint;
}
And then to assign a GeoPoint:
const example = new Example();
example.geopoint = new Geopoint(latitude, longitude);
Timestamps
You can represent date & time data in your Entity
files. Like geopoints,
our timestamp representation is essentially a wrapper of firestore's. You
can set the options for the field to updateOnWrite
which uses the server
timestamp when creating or updating documents, or updateOnCreate
or updateOnUpdate
.
class Example extends Entity {
@timestamp({
name: 'timestamp_property',
updateOnWrite: true,
})
timestamp!: ITimestamp;
}
Initialization Options
firestorm.intialize({ ...opts : IFireormConfig })
can be called
with the following options:
Option | Description | Type |
---|---|---|
fieldConversion |
Providing this option will convert Entity propertity names into firestore collection names so you don't need to provide the name option in @field() decorators. To view available values please check out the docs. |
enum FieldConversionType |
Important Gotcha's
-
All files for root collections, subcollections and nested maps must have a unique class name due to the way the metadata storage hooks everything up. We're currently looking for a way to resolve this issue.
-
Make sure fields such as geopoints, timestamps and document reference's have the
I
infront of the type, e.g.IDocumentRef
,ITimestamp
,IGeoPoint
.
Limitations
- Transactions and batched writes are currently unsupported.
If you would like to help resolve these issues, feel free to make a a pull request.
Development
Setup
- Clone the repo.
- Install dependencies.
cd firebase-firestorm
npm install
Testing
The testing script looks for *.spec.ts
files in the src
and test
directory.
npm test
Contributing
Found a bug?
Please report any bugs you have found submitting an issue to our Github repository, after ensuring the issue doesn't already exist. Alternatively, you can make a pull request with a fix.
Pull Requests
If you would like to help add or a feature or fix a bug, you can do so by making a pull request. The project uses Conventional Commits, so please make sure you follow the spec when making PRs. You must also include relevant tests.