UNPKG

@taraai/read-write

Version:

Synchronous NoSQL/Firestore for React

666 lines (620 loc) 21.4 kB
import isObject from 'lodash/isObject'; import isNumber from 'lodash/isNumber'; import isEmpty from 'lodash/isEmpty'; import trim from 'lodash/trim'; import cloneDeep from 'lodash/cloneDeep'; import has from 'lodash/has'; import { actionTypes } from '../constants'; export const snapshotCache = new WeakMap(); /** * Get DocumentSnapshot and QuerySnapshot with object from either data or * ordered firestore state. If provided with doc data, it will return * DocumentSnapshot, providing with a collection from data or an array from * ordered state will return QuerySnapshot, except ordered state that generated * as DocumentRef will return DocumentSnapshot * Note: the cache is local and, not persistance. Passing an object from initial * state or from SSR state will yield undefined. * @param {object|Array} obj - The object from data or ordered state * @returns {firebase.firestore.DocumentSnapshot|firebase.firestore.QuerySnapshot} * DocumentSnapshot or QuerySnapshot depend on type of object provided */ export function getSnapshotByObject(obj) { return snapshotCache.get(obj); } /** * Add where claues to Cloud Firestore Reference handling invalid formats * and multiple where statements (array of arrays) * @param {firebase.firestore.Reference} ref - Reference which to add where to * @param {Array} where - Where statement to attach to reference * @returns {firebase.firestore.Reference} Reference with where statement attached */ function addWhereToRef(ref, where) { if (!Array.isArray(where)) { throw new Error('where parameter must be an array.'); } if (Array.isArray(where[0])) { return where.reduce((acc, whereArgs) => addWhereToRef(acc, whereArgs), ref); } return ref.where(...where); } /** * Add attribute to Cloud Firestore Reference handling invalid formats * and multiple orderBy statements (array of arrays). Used for orderBy and where * @param {firebase.firestore.Reference} ref - Reference which to add where to * @param {Array} orderBy - Statement to attach to reference * @returns {firebase.firestore.Reference} Reference with where statement attached */ function addOrderByToRef(ref, orderBy) { if ( !Array.isArray(orderBy) && !(typeof orderBy === 'string' || orderBy instanceof String) ) { throw new Error('orderBy parameter must be an array or string.'); } if (typeof orderBy === 'string' || orderBy instanceof String) { return ref.orderBy(orderBy); } if (typeof orderBy[0] === 'string' || orderBy[0] instanceof String) { return ref.orderBy(...orderBy); } return orderBy.reduce( (acc, orderByArgs) => addOrderByToRef(acc, orderByArgs), ref, ); } /** * Convert cursor into a string array for spreading into cursor functions * @see https://firebase.google.com/docs/firestore/query-data/query-cursors#set_cursor_based_on_multiple_fields * @param {Array|string} cursor - The cursor as a string or string array * @returns {Array} String Array - The cursor as a string array */ function arrayify(cursor) { return [].concat(cursor); } /** * Call methods on ref object for provided subcollection list (from queryConfig * object) * @param {firebase.firestore.CollectionReference} ref - reference on which * to call methods to apply queryConfig * @param {Array} subcollectionList - List of subcollection settings from * queryConfig object * @returns {firebase.firestore.Query} Query object referencing path within * firestore */ function handleSubcollections(ref, subcollectionList) { if (Array.isArray(subcollectionList)) { subcollectionList.forEach((subcollection) => { /* eslint-disable no-param-reassign */ if (subcollection.collection) { if (typeof ref.collection !== 'function') { throw new Error( `Collection can only be run on a document. Check that query config for subcollection: "${subcollection.collection}" contains a doc parameter.`, ); } ref = ref.collection(subcollection.collection); } if (subcollection.id) ref = ref.doc(subcollection.id); if (subcollection.doc) ref = ref.doc(subcollection.doc); if (subcollection.where) ref = addWhereToRef(ref, subcollection.where); if (subcollection.orderBy) { ref = addOrderByToRef(ref, subcollection.orderBy); } if (subcollection.limit) ref = ref.limit(subcollection.limit); if (subcollection.startAt) { ref = ref.startAt(...arrayify(subcollection.startAt)); } if (subcollection.startAfter) { ref = ref.startAfter(...arrayify(subcollection.startAfter)); } if (subcollection.endAt) { ref = ref.endAt(...arrayify(subcollection.endAt)); } if (subcollection.endBefore) { ref = ref.endBefore(...arrayify(subcollection.endBefore)); } ref = handleSubcollections(ref, subcollection.subcollections); /* eslint-enable */ }); } return ref; } /** * Create a Cloud Firestore reference for a collection or document * @param {object} firebase - Internal firebase object * @param {object} meta - Metadata * @param {string} meta.path - Collection name * @param {string} meta.collectionGroup - Collection Group name * @param {string} meta.id - Document name * @param {Array} meta.where - List of argument arrays * @returns {firebase.firestore.Reference} Resolves with results of add call */ export function firestoreRef(firebase, meta) { if (!firebase.firestore) { throw new Error('Firestore must be required and initalized.'); } const { path, collection, collectionGroup, id, doc, subcollections, where, orderBy, limit, startAt, startAfter, endAt, endBefore, } = meta; let ref = firebase.firestore(); // TODO: Compare other ways of building ref const isInvalidGroup = collectionGroup ? path || collection : false; const isInvalidQuery = !(doc || id) ? !path && !collection : false; if (isInvalidGroup) { throw new Error( `Reference cannot contain both Path and CollectionGroup.` + ` (recieved: ${JSON.stringify(meta)})`, ); } if (!collectionGroup && isInvalidQuery) { throw new Error( `Query References must include a 'path' property.` + ` (recieved: ${JSON.stringify(meta)})`, ); } const { globalDataConvertor } = (firebase && firebase._ && firebase._.config) || {}; if (path || collection) ref = ref.collection(path || collection); if (collectionGroup) ref = ref.collectionGroup(collectionGroup); if (id || doc) ref = ref.doc(id || doc); ref = handleSubcollections(ref, subcollections); if (where) ref = addWhereToRef(ref, where); if (orderBy) ref = addOrderByToRef(ref, orderBy); if (limit) ref = ref.limit(limit); if (startAt) ref = ref.startAt(...arrayify(startAt)); if (startAfter) ref = ref.startAfter(...arrayify(startAfter)); if (endAt) ref = ref.endAt(...arrayify(endAt)); if (endBefore) ref = ref.endBefore(...arrayify(endBefore)); if (globalDataConvertor) ref = ref.withConverter(globalDataConvertor); return ref; } /** * Convert where parameter into a string notation for use in query name * @param {string} key - Key to use * @param {Array} value - Where config array * @returns {string} String representing where settings for use in query name */ function arrayToStr(key, value) { if (value instanceof Date || has(value, '_seconds')) { return `${key}=${new Intl.DateTimeFormat('en-US', { dateStyle: 'short', timeStyle: 'short', hour12: false, }).format(has(value, '_seconds') ? new Date(value * 1000) : value)}`; } if (typeof value === 'string' || value instanceof String || isNumber(value)) { return `${key}=${value}`; } if (typeof value[0] === 'string' || value[0] instanceof String) { return `${key}=${value.join(':')}`; } if (value && !Array.isArray(value) && typeof value.toString === 'function') { return `${key}=${value.toString()}`; } return value.map((val) => arrayToStr(key, val)); } /** * Pcik query params from object * @param {object} obj - Object from which to pick query params * @returns {object} Object of query params by name */ function pickQueryParams(obj) { return [ 'where', 'orderBy', 'limit', 'startAfter', 'startAt', 'endAt', 'endBefore', ].reduce((acc, key) => (obj[key] ? { ...acc, [key]: obj[key] } : acc), {}); } /** * Join/serilize query params * @param {object} queryParams - Query settings * @returns {string} Serialized string */ function serialize(queryParams) { return Object.keys(queryParams) .filter((key) => queryParams[key] !== undefined) .map((key) => arrayToStr(key, queryParams[key])) .join('&'); } /** * Create query name based on query settings for use as object keys (used * in listener management and reducers). * @param {object} meta - Metadata object containing query settings * @param {string} meta.collection - Collection name of query * @param {string} meta.collectionGroup - Collection Group name of query * @param {string} meta.doc - Document id of query * @param {string} meta.storeAs - User-defined Redux store name of query * @param {Array} meta.subcollections - Subcollections of query * @returns {string} String representing query settings */ export function getQueryName(meta) { if (typeof meta === 'string' || meta instanceof String) { return meta; } const { path, collection, collectionGroup, id, doc, subcollections, storeAs, ...remainingMeta } = meta; if (!path && !collection && !collectionGroup) { throw new Error('Path or Collection Group is required to build query name'); } if (storeAs) { return storeAs; } let basePath = path || collection || collectionGroup; if (id || doc) { basePath = basePath.concat(`/${id || doc}`); } if ((path || collection) && subcollections) { const mappedCollections = subcollections.map((subcollection) => getQueryName(subcollection), ); basePath = `${basePath}/${mappedCollections.join('/')}`; } const queryParams = pickQueryParams(remainingMeta); if (!isEmpty(queryParams)) { if (queryParams.where && !Array.isArray(queryParams.where)) { throw new Error('where parameter must be an array.'); } basePath = basePath.concat('?', serialize(queryParams)); } return basePath; } /** * Create query name based on query settings for use as object keys (used * in listener management and reducers). * @param {object} meta - Metadata object containing query settings * @param {string} meta.collection - Collection name of query * @param {string} meta.collectionGroup - Collection Group name of query * @param {string} meta.doc - Document id of query * @param {Array} meta.subcollections - Subcollections of query * @returns {string} String representing query settings */ export function getBaseQueryName(meta) { if (typeof meta === 'string' || meta instanceof String) { return meta; } const { path, collection, collectionGroup, subcollections, ...remainingMeta } = meta; if (!path && !collection && !collectionGroup) { throw new Error('Path or Collection Group is required to build query name'); } let basePath = path || collection || collectionGroup; if ((path || collection) && subcollections) { const mappedCollections = subcollections.map((subcollection) => getQueryName(subcollection), ); basePath = `${basePath}/${mappedCollections.join('/')}`; } const queryParams = pickQueryParams(remainingMeta); if (!isEmpty(queryParams)) { if (queryParams.where && !Array.isArray(queryParams.where)) { throw new Error('where parameter must be an array.'); } basePath = basePath.concat('?', serialize(queryParams)); } return basePath; } /** * Confirm that meta object exists and that listeners object exists on internal * firebase instance. If these required values do not exist, an error is thrown. * @param {object} firebase - Internal firebase object * @param {object} meta - Metadata object */ function confirmMetaAndConfig(firebase, meta) { if (!meta) { throw new Error('Meta data is required to attach listener.'); } if (!firebase || !firebase._ || !firebase._.listeners) { throw new Error( 'Internal Firebase object required to attach listener. Confirm that reduxFirestore enhancer was added when you were creating your store', ); } } /** * @description Update the number of watchers for a query * @param {object} firebase - Internal firebase object * @param {Function} dispatch - Redux's dispatch function * @param {object} meta - Metadata * @param {Function} unsubscribe - Unsubscribe function * @returns {object} Object containing all listeners */ export function attachListener(firebase, dispatch, meta, unsubscribe) { confirmMetaAndConfig(firebase, meta); const name = getQueryName(meta); if (!firebase._.listeners[name]) { firebase._.listeners[name] = unsubscribe; // eslint-disable-line no-param-reassign } dispatch({ type: actionTypes.SET_LISTENER, meta, payload: { name }, }); return firebase._.listeners; } /** * Remove/Unset a watcher * @param {object} firebase - Internal firebase object * @param {Function} dispatch - Redux's dispatch function * @param {object} meta - Metadata * @param {string} meta.collection - Collection name * @param {string} meta.doc - Document name */ export function detachListener(firebase, dispatch, meta) { const name = getQueryName(meta); if (firebase._.listeners[name]) { firebase._.listeners[name](); delete firebase._.listeners[name]; // eslint-disable-line no-param-reassign } const { preserveCacheAfterUnset: preserveCache } = firebase._.config || {}; dispatch({ type: actionTypes.UNSET_LISTENER, meta, payload: { name, preserveCache }, }); } /** * Turn query string into a query config object * @param {string} queryPathStr String to be converted * @param {string} parsedPath - Already parsed path (used instead of attempting parse) * @returns {object} Object containing collection, doc and subcollection */ export function queryStrToObj(queryPathStr, parsedPath) { const pathArr = parsedPath || trim(queryPathStr, ['/']).split('/'); const [collection, doc, ...subcollections] = pathArr; const queryObj = {}; if (collection) queryObj.collection = collection; if (doc) queryObj.doc = doc; if (subcollections.length) { queryObj.subcollections = [queryStrToObj('', subcollections)]; } return queryObj; } /** * Convert array of querys into an array of query config objects. * This normalizes things for later use. * @param {object|string} query - Query setups in the form of objects or strings * @returns {object} Query setup normalized into a queryConfig object */ export function getQueryConfig(query) { if (typeof query === 'string' || query instanceof String) { return queryStrToObj(query); } if (isObject(query)) { if ( !query.path && !query.id && !query.collection && !query.collectionGroup && !query.doc ) { throw new Error( 'Path, Collection Group and/or Id are required parameters within query definition object.', ); } return query; } throw new Error( 'Invalid Path Definition: Only Strings and Objects are accepted.', ); } /** * Convert array of querys into an array of queryConfig objects * @param {Array} queries - Array of query strings/objects * @returns {Array} watchEvents - Array of watch events */ export function getQueryConfigs(queries) { if (Array.isArray(queries)) { return queries.map(getQueryConfig); } if (typeof queries === 'string' || queries instanceof String) { return queryStrToObj(queries); } if (isObject(queries)) { return [getQueryConfig(queries)]; } throw new Error('Querie(s) must be an Array or a string.'); } /** * Get ordered array from snapshot * @param {firebase.database.DataSnapshot} snap - Data for which to create * an ordered array. * @returns {Array|null} Ordered list of children from snapshot or null */ export function orderedFromSnap(snap) { const ordered = []; if (snap.data && snap.exists) { const { id, ref: { parent: { path }, }, } = snap; const obj = isObject(snap.data()) ? { ...(snap.data() || snap.data), id, path } : { id, path, data: snap.data() }; snapshotCache.set(obj, snap); ordered.push(obj); } else if (snap.forEach) { snap.forEach((doc) => { const { id, ref: { parent: { path }, }, } = doc; const obj = isObject(doc.data()) ? { ...(doc.data() || doc.data), id, path } : { id, path, data: doc.data() }; snapshotCache.set(obj, doc); ordered.push(obj); }); } snapshotCache.set(ordered, snap); return ordered; } /** * Create data object with values for each document with keys being doc.id. * @param {firebase.database.DataSnapshot} snap - Data for which to create * an ordered array. * @returns {object|null} Object documents from snapshot or null */ export function dataByIdSnapshot(snap) { const data = {}; if (snap.data) { const snapData = snap.exists ? snap.data() : null; if (snapData) { snapshotCache.set(snapData, snap); data[snap.id] = { ...snapData, id: snap.id, path: snap.ref.parent.path, }; } else { data[snap.id] = null; } } else if (snap.forEach) { snap.forEach((doc) => { const snapData = doc.data() || doc; snapshotCache.set(snapData, doc); data[doc.id] = { ...snapData, id: doc.id, path: doc.ref.parent.path, }; }); } if (!!data && Object.keys(data).length) { snapshotCache.set(data, snap); return data; } return null; } /** * @private * @deprecated - populates is non-performant. * Create an array of promises for population of an object or list * @param {object} firebase - Internal firebase object * @param {object} populate - Object containing root to be populate * @param {object} populate.root - Firebase root path from which to load populate item * @param {string} id - String id * @returns {Promise} Resolves with populate child data */ /* istanbul ignore next: populates is deprecated and should not be used. */ export function getPopulateChild(firebase, populate, id) { return firestoreRef(firebase, { collection: populate.root, doc: id }) .get() .then((snap) => ({ id, ...snap.data() })); } const changeTypeToEventType = { added: actionTypes.DOCUMENT_ADDED, removed: actionTypes.DOCUMENT_REMOVED, modified: actionTypes.DOCUMENT_MODIFIED, }; /** * Action creator for document change event. Used to create action objects * to be passed to dispatch. * @param {object} change - Document change object from Firebase callback * @param {object} [originalMeta={}] - Original meta data of action * @returns {object} Resolves with doc change action object */ function docChangeEvent(change, originalMeta = {}) { const meta = { ...cloneDeep(originalMeta), path: change.doc.ref.parent.path }; if (originalMeta.subcollections && !originalMeta.storeAs) { meta.subcollections[0] = { ...meta.subcollections[0], doc: change.doc.id }; } else { meta.doc = change.doc.id; } const data = { id: change.doc.id, path: change.doc.ref.parent.path, ...change.doc.data(), }; return { type: changeTypeToEventType[change.type] || actionTypes.DOCUMENT_MODIFIED, meta, payload: { data, ordered: { oldIndex: change.oldIndex, newIndex: change.newIndex }, }, }; } /** * Dispatch action(s) response from listener response. * @private * @param {object} opts - Options object * @param {Function} opts.dispatch - Redux action dispatch function * @param {object} opts.firebase - Firebase instance * @param {object} opts.docData - Data object from document * @param {object} opts.meta - Meta data */ export function dispatchListenerResponse({ dispatch, docData = {}, meta, firebase, }) { const { mergeOrdered, mergeOrderedDocUpdates, mergeOrderedCollectionUpdates, } = firebase._.config || {}; const fromCache = docData && typeof docData.metadata.fromCache === 'boolean' ? docData.metadata.fromCache : true; const docChanges = typeof docData.docChanges === 'function' ? docData.docChanges() : docData.docChanges; // Dispatch different actions for doc changes (only update doc(s) by key) if (docChanges && docChanges.length < docData.size) { // Loop to dispatch for each change if there are multiple // TODO: Option for dispatching multiple changes in single action docChanges.forEach((change, index) => { const lastChange = index === docChanges.length - 1; dispatch(docChangeEvent(change, { reprocess: lastChange, ...meta })); }); } else { // Dispatch action for whole collection change dispatch({ type: actionTypes.LISTENER_RESPONSE, meta, payload: { data: dataByIdSnapshot(docData), ordered: orderedFromSnap(docData), fromCache, }, merge: { docs: mergeOrdered && mergeOrderedDocUpdates, collections: mergeOrdered && mergeOrderedCollectionUpdates, }, }); } }