Anew Firestore
One of the common differences between Firebase, a NoSQL solution, and any other SQL solution is it's extremely flexible. That is, the shape of data is left to the user: where each document (data unit) may follow a different shape. This unbounded flexibility may very easily lead to bad design patterns, and even worse, broken code. This is why @anew/firestore
was created: to ensure a strict shape for each document that lives under the same collection (data type).
Updates
For updates checkout Change Log.
Installation
To install @anew/firestore
directly into your project run:
npm i @anew/firestore -S
for yarn users, run:
yarn add @anew/firestore
Table of Contents
Collection
A collection in firestore is similar to a table in a SQL database solution. Collections contain what are called documents (similar to rows in a SQL database solutions). You can view a collection as a data type where each collection follows a common pattern (shape) that differs from other collections. To define a collection you must define a class as follows:
import firestore from 'path/to/firestore/instance'
import { Collection } from '@anew/firestore'
class CollectionName extends Collection {
/**
| ------------------------
| Required
| ------------------------
*/
static collection = firestore.collection('collectionName')
/**
| ------------------------
| Alternative 1
| ------------------------
| you may pass the firestore and collection name
*/
static name = 'collectionName'
static firestore = firestore
/**
| ------------------------
| Alternative 2
| ------------------------
| if the name is not passed the class name will be automatically used.
| So in this case `static name = CollectionName` will be used.
*/
static firestore = firestore
}
Other properties that you may define are:
class CollectionName extends Collection {
// ...
/**
| ------------------------
| Shape
| ------------------------
| This property defines the shape of each document that will
| live under this collection. This property is used to error
| data that is added to this collection that does not follow
| the define shape. This guarantees that any document write/read
| will have the define shape.
|
| The `Types` object used here is explained in detail below.
*/
static shape = {
name: Types.string,
date: Types.timestamp,
}
/**
| ------------------------
| Defaults
| ------------------------
| This property resolves document properties that are not defined.
| In this example, adding a document without defining `name` will resolve
| name to 'Guest'.
*/
static defaults = {
name: 'Guest',
}
}
Add
You may add/create a document as follows:
class CollectionName extends Collection {
// ...
/**
| Adding from a class method
*/
someAddMethod() {
this.add({
name: 'Brian',
date: new Date(),
})
}
/**
| Adding with reference
*/
someAddWithReferenceMethod() {
const newDocumentReference = this.ref()
this.add(newDocumentReference, {
name: 'Brian',
date: new Date(),
})
/**
| Alternatively
| ------------------------
| You may pass document id instead of reference
*/
this.add('DocumentId', {
name: 'Brian',
date: new Date(),
})
}
}
const collectionName = new CollectionName()
/**
| Adding from a class instance
*/
collectionName.add({
name: 'Brian',
date: new Date(),
})
Query
The query
method is similar to firebase's where
method with slight differences. The first difference is that they may be used with actions other than get
as well. These actions are get
, update
, and delete
. You may create query as follows:
class CollectionName extends Collection {
// ...
/**
| The following query matches a single document's unique id/reference
*/
idQuery() {
this.query(documentId)
// Alternative
this.query(documentReference)
}
/**
| You may pass an array of ids/references to get multiple document
*/
idsQuery() {
this.query(documentOneId, documentTwoId, ...documentIds)
/* or */
this.query(documentOneReference, documentTwoReference, ...documentReferences)
// Alternative 1
this.query([documentOneId, documentTwoId, ...documentIds])
/* or */
this.query([documentOneReference, documentTwoReference, ...documentReferences])
// Alternative 2
this.query(documentOneId)
.query(documentTwoId)
.query(...documentIds)
/* or */
this.query(documentOneReference)
.query(documentTwoReference)
.query(...documentReferences)
}
/**
| The follow is a compound query that checks aganist multiple
| document properties. The following query is a composition (AND) query
| where only document that match all queries are selected.
*/
compoundQuery() {
this.query('name', '==', name).query('date', '<=', maxDate)
// Alternative
this.query([['name', '==', name], ['date', '<=', maxDate]])
}
}
You may use queries with actions as follows:
// Get all matched documents
this.query('name', '==', name).query('date', '<=', maxDate).get()
// Delete all matched documents
this.query('name', '==', name).query('date', '<=', maxDate).delete()
// Update all matched documents
this.query('name', '==', name).query('date', '<=', maxDate).update({...})
Each method used here is explained in detail below.
Get
The get
method is used to retrieve documents from firebase. You may use the get method as follows:
// The get method takes two optional arguments a query and a reducer.
// The query argument is used to get only document that match the passed query.
// The reducer argument is used to reduce each document retrieved to a new value.
this.get(query, reducer)
// Alternative
this.query(query).get(reducer)
Update
The update
method is used to modify documents in firebase. You may use the update method as follows:
this.update(query, update)
// Alternative
this.query(query).update(update)
Delete
The delete
method is used to remove documents from firebase. You may use the delete method as follows:
this.delete(query)
// Alternative
this.query(query).delete()
Utilities
/**
| Generate a Document Reference
| @param {String} id You may OPTIONALLY pass a specific id for the reference
*/
this.ref(id)
/**
| Generate a Document Id
*/
this.id()
/**
| Convert { lat, long } to GeoPoint object
| @param {Number} options.lat Latitude
| @param {Number} options.long Longitude
*/
this.geopoint({ lat, long })
/**
| Alternatively
| -----------------------
| You may use the GeoPoint class
*/
new Collection.GeoPoint(lat, long)
/**
| Convert Date object to Timestamp object
| @param {Date} date JavaScript Date Object
*/
this.timestamp(date)
/**
| Alternatively
| -----------------------
| You may use the Timestamp class
*/
new Collection.Timestamp(date)
Listeners
The following listeners may be defined as follows:
class CollectionName extends Collection {
// ...
// The onRead listeners is applied to all `get/watch` calls.
// It is a global reducer that changes all documents retrieved.
onRead(data) {
return {
...date,
newProperty: true,
}
}
// The onWrite listener is applied to all `update/add` calls.
// It is a global reducer that changes data before it is written to a document.
onWrite(data) {
if (data.location) {
data = {
...data,
// convert to geolocation before write
location: this.geopoint(data.location),
}
}
return data
}
// You may also define a listener per write action
onAdd(data) {...}
onUpdate(data) {...}
}
Types
The Types
object is used to define the shape
property in a Collection
. These type checks may also be used in the application.
import { Types } from '@anew/firestore'
// Check if argument is a boolean
Types.boolean(arg)
// Check if argument is null
Types.null(arg)
// Check if argument is a string
Types.string(arg)
// Check if argument is a number
Types.number(arg)
// Check if argument is a between a range
Types.number.range(0, 5)(arg)
// Check if argument is an object (also known as map in firebase)
Types.map(arg)
// Check if argument is an array
Types.array(arg)
// Check if argument is an array of strings
Types.array.of(Types.string)(arg)
// Check if argument is a timestamp
Types.timestamp(arg)
// Check if argument is a geopoint
Types.geopoint(arg)
// Check if argument is a document reference specific to `collectionName`
Types.ref('collectionName')(arg)
// Check if argument is a document reference without a specifying a collection
Types.ref.any(arg)
// Check if argument is an object that contains the passed properties with their types.
Types.shape({
name: Types.string,
price: Types.number,
})(arg)
// Check if argument matches one of the passed values
Types.oneOf([1, 'One'])(arg)
// Check if argument passes one of the types in the list
Types.oneOfType([Types.string, Types.number])(arg)
Example
The following is a basic application of @anew/firestore
.
Basic Firebase Setup
// src/firebase.js
import firebase from 'firebase/app'
import 'firebase/firestore'
const app = firebase.initializeApp({
apiKey: process.env.REACT_APP_API_KEY,
authDomain: process.env.REACT_APP_AUTH_DOMAIN,
databaseURL: process.env.REACT_APP_DATABASE_URL,
projectId: process.env.REACT_APP_PROJECT_ID,
storageBucket: process.env.REACT_APP_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID,
})
export const firestore = app.firestore()
Company Collection
// src/company.js
import { firestore } from './firebase' // setup file above
import { Types, Collection } from '@anew/firestore'
class Company extends Collection {
static collection = firestore.collection('company')
static shape = {
name: Types.string,
margin: Types.number,
}
}
export default new Company()
Product Collection
// src/product.js
import Company from './company' // company file above
import { firestore } from './firebase' // setup file above
import { Types, Collection } from '@anew/firestore'
class Product extends Collection {
static collection = firestore.collection('product')
static shape = {
name: Types.string,
price: Types.number,
location: Types.geopoint,
dateAdded: Types.timestamp,
discount: prop => {
return prop < 50
},
company: Types.shape({
name: Types.string,
ref: Types.ref(Company),
}),
}
static defaults = {
discount: 0,
price: 0,
location: {
lat: 0,
long: 0,
},
dateAdded: () => {
return new Date()
},
}
/*
| ----------------
| Listeners
| ----------------
*/
onRead({ dateAdded, location, ...data }) {
return {
...data,
dateAdded: dateAdded.toDate(),
location: {
lat: location.latitude,
long: location.longitude,
},
}
}
onAdd({ location, ...data }) {
return {
...data,
location: new this.types.GeoPoint(location.lat, location.long),
}
}
onUpdate({ location, ...data }) {
if (location) {
return {
...data,
location: new this.types.GeoPoint(location.lat, location.long),
}
}
return data
}
/*
| ----------------
| Reducers
| ----------------
*/
async detailedReducer(data) {
return {
...data,
company: await Company.get(data.company.ref),
}
}
/*
| ----------------
| Getters
| ----------------
*/
getWithDetails(id) {
return this.query(id).get(this.detailedReducer)
}
/*
| ----------------
| Setters
| ----------------
*/
async updatePrice(data) {
let { company } = data
if (!company.margin) {
company = await data.company.ref.get()
}
return await this.query(data.id).update({
price: data.cost * company.margin,
})
}
updateByName(name, update) {
return this.query('name', '==', name).update(update)
}
deleteAllExpired(expiredDate) {
return this.query('date', '<=', expiredDate).delete()
}
}
export default new Product()