diff --git a/docs/read.md b/docs/read.md new file mode 100644 index 00000000..ac43ac1d --- /dev/null +++ b/docs/read.md @@ -0,0 +1,437 @@ +# Read + +- [Basic Reads](#advanced-reads) +- [Partial Reads](#partial-reads) +- [Value Reads](#value-reads) +- [Query Syntax](#query-syntax) +- [useCache](#advanced-reads) + +--- + +## Basic Reads + + +`useRead({ path, ...query })` + +Query/Load document & subscribe to live updates from Firestore. + +```ts +const singleTask = useRead({ + path: 'tasks', + id: 'task-one' +}); + +const tasks = useRead({ + path: 'tasks', + where: [ + ['status', '==', 'done'], + ['assignee', '==', myUID] + ], + orderBy: ['createdAt', 'desc'], +}); +``` + +**loading state**\ +When a `useRead` is called initially, and data is not already loaded in memory, +it will return an `undefined`. Just like in React 18, the recommendation is +if a loader is needed then return null from the component. Then use a parent +component to decide whether to show a loader or not. + +**not found state**\ +If the document is not found after reading from the database it will +return a `null`. + +**error state**\ +Create a parent/ancestor be React ErrorBoundries to catch and handle any errors. + +**Typescript Types**\ +`useRead` uses function overloading. This means it can return the data requested +depending on what was asked for. Below are the types for a single document load +or loading a list of documents from a query.\ +For full query syntax details [jump to #ReadQuerySyntax](#query-syntax). +```ts +type PathId = { id:string; path: string; }; +type Document = FirestoreDocument & PathId; +type Loading = undefined; +type NotFound = null; + +// Single Document +function useRead( + pathId: PathId +): Document | Loading | NotFound; + +// Multiple Documents +function useRead( + query: { path:string; } & Optional, +): Document[] | Loading | NotFound; +``` + +## Partial Reads + +`useRead({ path, ...query }, [...keysOfDocument])` + +Query & load & subscribe to live updates from Firestore but only return a partial of top-level properties. + +```ts +const { title, status } = useRead({ + path: 'tasks', + id: 'task-one' + }, + ['title', 'status'] +); + +const taskTitlePartials = useRead({ + path: 'tasks', + where: [ + ['status', '==', 'done'], + ['assignee', '==', myUID] + ], + orderBy: ['createdAt', 'desc'], + }, + ['title'] +); +``` + +**Typescript Types** +```ts +// Partial +function useRead( + pathId: PathId, + fields: (keyof Document)[], +): Pick | Loading | NotFound; + +// Multiple Partials +function useRead( + query: { path:string; } & Optional, + fields: (keyof Document)[], +): Pick[] | Loading | NotFound; +``` + +## Value Reads + +`useRead({ path, ...query }, keysOfDocument)` + +Query & load & subscribe to live updates from Firestore but only return +the value of a single top-level property. + +```ts +const title = useRead({ + path: 'tasks', + id: 'task-one' + }, + 'title' +); + +const taskTitleStrings = useRead({ + path: 'tasks', + where: [ + ['status', '==', 'done'], + ['assignee', '==', myUID] + ], + orderBy: ['createdAt', 'desc'], + }, + 'title' +); +``` + +**Typescript Types** +```ts +// Load a value +function useRead( + pathId: PathId, + field: keyof Document, +): Document[keyof Document] | Loading | NotFound; + +// Load values from multiple documents +function useRead( + query: { path:string; } & Optional, + field: keyof Document, +): (Document[keyof Document])[] | Loading | NotFound; +``` + + +## Alias Reads + +The most used advanced read will be an alias. Reads will subscribe +Firestore to updates on the doc(s) requested until the component +is unmounted. To minimize listeners you can pass a second argument +to return the reads alias(es). Those aliases can be passed into +the `useCache` function. `useCache` is only a Redux selector to +get the results and _does not_ add more Firestore .onSnapshot listeners. +In order to get just the alias the second argument is a special +enum of `::alias`. +```ts +const taskAlias = useRead( + { path: 'tasks' }, + '::alias' +); +``` +Example of usage +```ts +function ParentComponent() { + const taskAlias = useRead({ path: 'tasks' }, '::alias'); + return ; +} + +function ChildComponent ({taskAlias}) { + const tasks = useRead(taskAlias); + + if (tasks === undefined) return null; + + return task.map((doc) => (
  • `{doc.path}/${doc.id}`
  • ); +} +``` + +**Typescript Types** +```ts +// Get alias for a single document +function useRead( + pathId: PathId, + hasAlias: '::alias', +): String; + +// Get alias for a query +function useRead( + query: { path:string; } & Optional, + hasAlias: '::alias', +): String; +``` + + +## Query Syntax + + +A 1-to-1 mirror of [Firestore Queries](https://firebase.google.com/docs/firestore/query-data/queries) + +##### Entire Collection + +```js +{ path: 'users' } +``` + +##### Single Document + +```js +{ path: 'users', id: 'puppybits' } +``` + +##### Enitre Sub-collection + +```js +{ path: 'orgs/my-workspace/tasks' } +``` + +##### Collection Group + +Collection Groups are all Collections of that +have the same collection name, regardless of hierarchy. +```js +// task collection is under //tasks +// this gets all tasks regardless of nesting +{ collectionGroup: 'tasks' }, +``` + +##### Where + +To create a single `where` call, pass a single argument array to the `where` parameter: + +```js +{ + path: 'orgs/my-workspace/tasks', + where: ['status', '==', 'done'] +}, +``` + +Multiple `where` queries are as simple as passing multiple argument arrays (each one representing a `where` call): + +```js +{ + path: 'orgs/my-workspace/tasks', + where: [ + ['status', '==', 'done'] + ['subtasks', '<', 2] + ] +}, +``` + +Firestore doesn't allow you to create `or` style queries. Use the `in` option with an array of options. Firestore only support 10 +items in each `in` where so it will be broken up into multiple calls to firestore and returned +as a single result. + +```javascript +{ + path: 'users', + where: [ + ['assignee', 'in', ['alice', 'bob', 'iba']], + ['isOnline', '==', true] + ] +} +``` + +###### Where Clause + +All Firestore [Where Clause](https://firebase.google.com/docs/reference/js/firestore_) are supported. +- `<` +- `<=` +- `==` +- `!=` +- `>=` +- `>` +- `array-contains` +- `array-contains-any` +- `in` +- `not-in` + +##### orderBy + +To create a single `orderBy` call, pass a single argument array to `orderBy` + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: ['assignee', 'asc'], +}, +``` + +Multiple `orderBy`s are as simple as passing multiple argument arrays (each one representing a `orderBy` call) + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: [ + ['assignee', 'desc'], + ['status'] + ] +}, +``` + +##### limit + +Limit the query to a certain number of results + +```js +{ + path: 'orgs/my-workspace/tasks', + limit: 10 +}, +``` + + +##### startAt + +> Creates a new query where the results start at the provided document (inclusive) + +[From Firebase's `startAt` docs](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#startAt) + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: 'dueDate', + startAt: new Date(), +}, +``` + +_Can only be used with collections. Types can be a string, number, Date object, or an array of these types, but not a Firestore Document Snapshot_ + +##### startAfter + +> Creates a new query where the results start after the provided document (exclusive)... + +[From Firebase's `startAfter` docs](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#startAfter) + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: ['dueDate', 'assignee'], + startAt: [new Date(), 'alice'], +} +``` + +_Can only be used with collections. Types can be a string, number, Date object, or an array of these types, but not a Firestore Document Snapshot_ + +##### endAt + +> Creates a new query where the results end at the provided document (inclusive)... + +[From Firebase's `endAt` docs](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#endAt) + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: ['dueDate', 'assignee'], + endAt: [new Date(), 'alice'], +} +``` + +_Can only be used with collections. Types can be a string, number, Date object, or an array of these types, but not a Firestore Document Snapshot_ + +##### endBefore + +> Creates a new query where the results end before the provided document (exclusive) ... + +[From Firebase's `endBefore` docs](https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#endBefore) + +```js +{ + path: 'orgs/my-workspace/tasks', + orderBy: 'dueDate', + endBefore: new Date(), +}, +``` + +_Can only be used with collections. Types can be a string, number, Date object, or an array of these types, but not a Firestore Document Snapshot_ + +--- +## Advanced Usage + +## useCache + +Under the covers `useRead` returns a memoize `useCache` hook. + +The `useRead` hook already de-dupe any extra listeners. Only one listener will +be attached for Firestore using the `.onSnapshotListener` method. Any addiontional +calls will be registered and the listener for Firestore will only be removed +after _all_ components using the data is unmounted. + +`useCache` is publicly available but shouldn't need to be used. All it really +does is grab the data but doesn't listen for changed. In Redux terminology +`useCache` is a memoize selector and `useRead` dispatches an action to load +data and returns a memoize selector which contains the results. + +`useCache({ path, id })` + +Select a document directly from the normalized, in-memory Redux store. +```ts +const readTask = useCache({ + path: 'tasks', + id: taskOne.id +}); +``` + +`useCache(alias)` + +Select a document directly from the normalized, in-memory Redux store. +```ts +const myAlias = useRead({ + path: 'tasks', + where: ['status', '!=', 'done'], +}, '::alias'); + +const taskList = useCache(myAlias); +``` + +Cache also accepts multiple reads and a mix of queries and document fetches. +```ts +const [taskAlias, doneTaskAlias] = useRead([ + { path: 'tasks', id:'my-task' }, + { + path: 'tasks', + where: ['status', '!=', 'done'], + } +], '::alias'); + +const [myTaskDoc, doneTaskList] = useCache([taskAlias, doneTaskAlias]); +``` + + + diff --git a/index.d.ts b/index.d.ts index b959017d..510948bd 100644 --- a/index.d.ts +++ b/index.d.ts @@ -128,7 +128,8 @@ export type WriteFn = (readKeys: string) => Write | Write[]; * a second time in the transaction. This is a best effort. Full * Transactional Query support is only available with firebase-admin. */ -export type ReadQuery = Omit & { _slowCollectionRead: true }; +export type ReadQuery = Omit & + Pick & { alias: string }; export type Transaction = { reads: (Read | ReadQuery)[]; @@ -146,6 +147,163 @@ export type Transaction = { */ export type mutate = (operations: Write | Batch | Transaction) => Promise; +// -- useRead subscriptions + +type Loading = undefined; +type NotFound = null; + +/** + * read subscription for a single doc + * @param query ReadQuery + * @returns Docs + */ +function useRead(pathId: PathId): Doc | Loading | NotFound; +/** + * read single value from cache + * @param pathId PathId + * @param field keyof Doc + * @returns Value of key + */ +function useRead( + pathId: PathId, + field: K, +): Doc[K] | Loading | NotFound; +/** + * read single value from cache + * @param pathId PathId + * @param field keyof Doc + * @returns Value of key + */ +function useRead( + pathId: PathId, + fields: K[], +): Pick | Loading | NotFound; +/** + * read subscription to get docs + * @param query ReadQuery + * @returns Docs + */ +function useRead( + query: Omit, +): Doc[] | Loading | NotFound; +/** + * read subscription for single value + * @param query ReadQuery + * @param field keyof Doc + * @returns Value of key + */ +function useRead( + query: Omit, + field: K, +): Doc[K][] | Loading | NotFound; +/** + * read subscription for selected values + * @param query ReadQuery + * @param field keyof Doc + * @returns Value of key + */ +function useRead( + query: Omit, + fields: K[], +): Pick[] | Loading | NotFound; +/** + * read subscription that returns the single alias + * @param query ReadQuery + * @param aliasEnum '::alias' + * @returns Alias for the query + */ +function useRead( + query: Omit, + alias: '::alias', +): string; +/** + * read from cache + * @param alias string + * @returns Alias for the query + */ +function useRead(alias: string): Doc[] | undefined; +/** + * read from cache + * @param alias string + * @param field field of doc + * @returns Select keys from doc + */ +export function useRead( + alias: string, + field: K, +): Doc[K][] | Loading | NotFound; +/** + * read from cache + * @param alias string + * @param fields keys of doc + * @returns Select keys from doc + */ +export function useRead( + alias: string, + fields: K[], +): Pick[] | Loading | NotFound; + +// -- useCache reads from cache + +/** + * reads single doc from cache + * @param query ReadQuery + * @returns Docs + */ +function useCache(pathId: PathId): Doc; +/** + * read single value from cache + * @param pathId PathId + * @param field keyof Doc + * @returns Value of key + */ +function useCache(pathId: PathId, field: K): Doc[K]; +/** + * read single value from cache + * @param pathId PathId + * @param field keyof Doc + * @returns Value of key + */ +function useRead( + pathId: PathId, + fields: K[], +): Pick | undefined; +/** + * read from cache + * @param alias string + * @returns Alias for the query + */ +function useCache(alias: string): Doc[] | undefined; +/** + * read from cache + * @param alias string + * @param field field of doc + * @returns Select keys from doc + */ +export function useCache( + alias: string, + field: K, +): Doc[K][] | undefined; +/** + * read from cache + * @param alias string + * @param fields keys of doc + * @returns Select keys from doc + */ +export function useCache( + alias: string, + fields: K[], +): Pick[] | undefined; + +// -- setCache for storybook + +export function setCache(aliases: Record, middlewares?: any): any; + +export function shouldPass(actionCreatorFnc: any): any; +export function shouldPass(testname: string, actionCreatorFnc: any): any; +export function shouldFail(actionCreatorFnc: any): any; +export function shouldFail(testname: string, actionCreatorFnc: any): any; + /** * A redux store enhancer that adds store.firebase (passed to React component * context through react-redux's ). diff --git a/package.json b/package.json index fb278cb6..2c48bc18 100644 --- a/package.json +++ b/package.json @@ -38,10 +38,13 @@ "pre-push": "npm run lint" }, "dependencies": { - "debug": "^4.3.2", - "immer": "9.0.5", + "debug": "^4.3.3", + "immer": "9.0.12", "lodash": "^4.17.21", + "react-dom": "^17.0.2", + "react-redux": "^7.2.6", "reduce-reducers": "^1.0.4" + "react-redux-firebase": "^3.11.0", }, "devDependencies": { "@babel/cli": "^7.14.5", @@ -52,6 +55,7 @@ "@babel/preset-react": "^7.14.5", "@babel/register": "^7.14.5", "@babel/runtime": "^7.14.6", + "@reduxjs/toolkit": "^1.7.1", "babel-eslint": "^10.1.0", "babel-loader": "^8.2.2", "babel-plugin-lodash": "^3.3.4", @@ -65,18 +69,18 @@ "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-babel": "^5.3.1", + "firebase": "^9.6.5", + "redux": "^4.1.2", "eslint-plugin-import": "^2.23.4", "eslint-plugin-jsdoc": "^35.4.3", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.4.0", "eslint-plugin-react": "^7.24.0", - "firebase": "^8.7.1", "husky": "^7.0.1", "kelonio": "0.6.0", "mocha": "^7.0.1", "nyc": "^15.1.0", "prettier": "2.3.2", - "redux": "^4.1.0", "rimraf": "^3.0.2", "sinon": "^11.1.1", "sinon-chai": "^3.7.0", diff --git a/src/hooks/useCache.js b/src/hooks/useCache.js new file mode 100644 index 00000000..a0192a27 --- /dev/null +++ b/src/hooks/useCache.js @@ -0,0 +1,111 @@ +import { has, isEqual, isPlainObject, pick } from 'lodash'; +import { useRef, useMemo, useEffect, useCallback } from 'react'; +import { useSelector } from 'react-redux'; + +const document = ({ databaseOverrides = {}, database = {} }, [path, id]) => { + const override = databaseOverrides[path] && databaseOverrides[path][id]; + const doc = database[path] && database[path][id]; + if (override) return { ...doc, ...override }; + return doc; +}; + +const selectDocument = (cache, id, path, fields = null) => { + if (id === undefined || path === undefined) return undefined; + const doc = document(cache, [path, id]); + if (!doc) return doc; + if (typeof fields === 'string') { + return doc[fields]; + } + if (doc && fields) { + return pick(doc, ['id', 'path', ...fields]); + } + return doc; +}; + +const selectList = (cache, result, fields = null) => { + if (!result || !result.ordered) return undefined; + const docs = result.ordered.reduce((arr, [path, id]) => { + const doc = selectDocument(cache, id, path, fields); + if (doc) { + arr.push(doc); + } + return arr; + }, []); + + return result.id && Array.isArray(docs) ? docs[0] : docs; +}; + +const selectAlias = (state, alias) => + (state && + state.firestore && + state.firestore.cache && + state.firestore.cache[alias]) || + undefined; + +/** + * set/uset listeners and return a selector to it. + * Note: functions are supported but don't use them. + * @param { PathId | PathIds[] | ReadQuery.alias } alias + * @param { null | string } selection + * @return Identifiables[] + */ +export default function useCache(alias, selection = null) { + const value = typeof selection === 'string' ? selection : null; + const fields = Array.isArray(selection) ? selection : value; + const postFnc = + typeof selection === 'function' ? useCallback(selection) : null; + + const aliasRef = useRef(has(alias, 'path') || alias); + + useEffect(() => { + if (!alias) return; + + const newAlias = has(alias, 'path') || alias; + if (!isEqual(newAlias, aliasRef.current)) { + aliasRef.current = newAlias; + console.log('alias change', newAlias, aliasRef.current); + } + }, [alias]); + + const selector = useMemo( + () => + function readSelector(state) { + const { firestore: { cache } = {} } = state || {}; + if (!cache || !aliasRef.current) return undefined; + + const aliases = aliasRef.current; + + const isPathId = has(aliasRef.current, 'path'); + if (isPathId) { + return selectDocument( + cache, + aliasRef.current.id, + aliasRef.current.path, + fields, + ); + } + + const isMultiple = Array.isArray(aliases); + const listsAndDocs = (isMultiple ? aliases : [aliases]).map((alias) => { + const isAlias = typeof alias === 'string'; + if (isAlias) { + const key = selectAlias(state, alias); + if (!key) return undefined; + return selectList(cache, key, fields); + } + + return selectDocument(cache, alias.id, alias.path, fields); + }); + + if (isMultiple) { + return postFnc ? postFnc(listsAndDocs) : listsAndDocs; + } + + return (postFnc ? postFnc(listsAndDocs) : listsAndDocs)[0]; + }, + [aliasRef.current, postFnc, fields], + ); + + // All data from firestore is standard JSON except Timestamps + return useSelector(selector, isEqual); +} diff --git a/src/hooks/useRead.js b/src/hooks/useRead.js new file mode 100644 index 00000000..efb2caa2 --- /dev/null +++ b/src/hooks/useRead.js @@ -0,0 +1,63 @@ +import { filter, isEqual, some } from 'lodash'; +import { useRef, useMemo, useEffect } from 'react'; +import { useFirestore } from 'react-redux-firebase'; +import { getQueryName } from '../utils/query'; +import useCache from './useCache'; + +const getChanges = (data = [], prevData = []) => { + const result = {}; + result.added = filter(data, (d) => !some(prevData, (p) => isEqual(d, p))); + result.removed = filter(prevData, (p) => !some(data, (d) => isEqual(p, d))); + return result; +}; + +/** + * set/unset listeners and return a selector to it. + * + * @param {*} queries + * @param {*} selection + * @return Selector | string + */ +export default function useRead(queries, selection = null) { + const firestore = useFirestore(); + + const firestoreIsEnabled = !!firestore; + const queryRef = useRef(); + const aliasRef = useRef(); + + useEffect(() => { + if (firestoreIsEnabled && queries && !isEqual(queries, queryRef.current)) { + const queryArray = Array.isArray(queries) ? queries : [queries]; + const changes = getChanges(queryArray, queryRef.current); + + queryRef.current = queryArray; + aliasRef.current = queryRef.current.map(getQueryName); + + // Remove listeners for inactive subscriptions + firestore.unsetListeners(changes.removed); + + // Add listeners for new subscriptions + firestore.setListeners(changes.added); + } + }, [aliasRef.current]); + + useEffect( + () => () => { + if (firestoreIsEnabled && queryRef.current) { + firestore.unsetListeners(queryRef.current); + } + }, + [], + ); + + if (selection === '::alias') { + return Array.isArray(queries) + ? queries.map(getQueryName) + : getQueryName(queries); + } + + return useCache( + Array.isArray(queries) ? queries.map(getQueryName) : getQueryName(queries), + selection, + ); +} diff --git a/src/index.js b/src/index.js index d6afb805..21d46c5d 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,9 @@ import createFirestoreInstance, { import constants, { actionTypes } from './constants'; import middleware, { CALL_FIRESTORE } from './middleware'; import { getSnapshotByObject } from './utils/query'; +import useRead from './hooks/useRead'; +import useCache from './hooks/useCache'; + // converted with transform-inline-environment-variables export const version = process.env.npm_package_version; @@ -28,6 +31,8 @@ export { createFirestoreInstance, firestoreActions as actions, getFirestore, + useRead, + useCache, getSnapshotByObject, constants, actionTypes, @@ -41,6 +46,8 @@ export default { reducer, firestoreReducer: reducer, enhancer, + useRead, + useCache, reduxFirestore: enhancer, createFirestoreInstance, actions: firestoreActions, diff --git a/test/unit/hooks/useCache.spec.js b/test/unit/hooks/useCache.spec.js new file mode 100644 index 00000000..a34c5a36 --- /dev/null +++ b/test/unit/hooks/useCache.spec.js @@ -0,0 +1,34 @@ +import createFirestoreInstance from 'createFirestoreInstance'; + +describe('createFirestoreInstance', () => { + describe('exports', () => { + it('a functions', () => { + expect(createFirestoreInstance).toBeInstanceOf(Function); + }); + }); + + describe('firestoreInstance', () => { + it('sets internal parameter _', () => { + const instance = createFirestoreInstance({}, {}); + expect(instance).toHaveProperty('_'); + }); + + it('attaches provided config to internal _.config object', () => { + const testVal = 'test'; + const instance = createFirestoreInstance({}, { testVal }); + expect(instance).toHaveProperty('_.config.testVal', testVal); + }); + + describe('options - ', () => { + describe('helpersNamespace -', () => { + it('places helpers on namespace if passed', () => { + const instance = createFirestoreInstance( + {}, + { helpersNamespace: 'test' }, + ); + expect(instance).toHaveProperty('test'); + }); + }); + }); + }); +});