├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json └── src ├── ActionCreators.js ├── ActionTypes.js ├── SubscriptionRecord.js ├── __tests__ ├── queries-test.js └── resolvers-test.js ├── createFirebaseMiddleWare.js ├── createFirebaseReducers.js ├── fetch.js ├── index.js ├── operations.js ├── queries.js ├── resolvers.js └── types.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "loose": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rackt", 3 | "rules": { 4 | "valid-jsdoc": 2 5 | }, 6 | "plugins": [ 7 | "react" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | dist 25 | lib 26 | 27 | # Dependency directory 28 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 29 | node_modules 30 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/colbyr/redux-firebase/9152003c0c19737e5376a8259255f04a41d0e874/.npmignore -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Colby Rabideau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-firebase 2 | Declarative firebase queries for redux. 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-firebase", 3 | "version": "0.3.3", 4 | "description": "Declarative firebase queries for redux.", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib", 8 | "build-dev": "babel src --out-dir lib --watch", 9 | "check": "npm run lint && npm run test", 10 | "clean": "rm -rf lib", 11 | "lint": "eslint src", 12 | "prepublish": "npm run clean && npm run build", 13 | "test": "jest" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/colbyr/redux-firebase.git" 18 | }, 19 | "keywords": [ 20 | "firebase", 21 | "redux", 22 | "react" 23 | ], 24 | "author": "Colby Rabideau ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/colbyr/redux-firebase/issues" 28 | }, 29 | "homepage": "https://github.com/colbyr/redux-firebase#readme", 30 | "peerDependencies": { 31 | "firebase": "^2.3.1", 32 | "immutable": "^3.7.4", 33 | "ramda": "^0.18.0", 34 | "react": "^0.14.2", 35 | "react-redux": "^4.0.0", 36 | "redux": "^3.0.4", 37 | "redux-actions": "^0.8.0" 38 | }, 39 | "devDependencies": { 40 | "babel": "^5.5.8", 41 | "babel-core": "^5.6.18", 42 | "babel-eslint": "^4.1.0", 43 | "babel-jest": "^5.3.0", 44 | "debounce": "^1.0.0", 45 | "eslint": "^1.7.3", 46 | "eslint-config-rackt": "1.0.0", 47 | "eslint-plugin-react": "^3.6.3", 48 | "jest-cli": "^0.7.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ActionCreators.js: -------------------------------------------------------------------------------- 1 | import { 2 | PATHS_SYNCED, 3 | PATHS_SUBSCRIBED, 4 | PATHS_UNSUBSCRIBED, 5 | } from './ActionTypes' 6 | import { createAction } from 'redux-actions' 7 | 8 | const doSubscribe = createAction(PATHS_SUBSCRIBED) 9 | export function subscribe(sid, paths) { 10 | return doSubscribe({paths, sid}) 11 | } 12 | 13 | const doSync = createAction(PATHS_SYNCED) 14 | export function sync(updates) { 15 | return doSync(updates) 16 | } 17 | 18 | const doUnsubscribe = createAction(PATHS_UNSUBSCRIBED) 19 | export function unsubscribe(sid, paths) { 20 | return doUnsubscribe({paths, sid}) 21 | } 22 | -------------------------------------------------------------------------------- /src/ActionTypes.js: -------------------------------------------------------------------------------- 1 | const PREFIX = '@@redux-firebase/' 2 | 3 | export const PATHS_SUBSCRIBED = `${PREFIX}PATHS_SUBSCRIBED` 4 | export const PATHS_SYNCED = `${PREFIX}PATHS_SYNCED` 5 | export const PATHS_UNSUBSCRIBED = `${PREFIX}PATHS_UNSUBSCRIBED` 6 | -------------------------------------------------------------------------------- /src/SubscriptionRecord.js: -------------------------------------------------------------------------------- 1 | import { Record, Set } from 'immutable' 2 | 3 | export default Record({ 4 | handler: null, 5 | subscribers: Set(), 6 | }) 7 | -------------------------------------------------------------------------------- /src/__tests__/queries-test.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | 3 | jest 4 | .dontMock('../queries') 5 | .dontMock('../resolvers') 6 | .dontMock('../types') 7 | 8 | describe('queries', () => { 9 | let queries 10 | let resolvers 11 | let types 12 | 13 | let mockCache 14 | let mockState 15 | 16 | beforeEach(() => { 17 | queries = require('../queries') 18 | resolvers = require('../resolvers') 19 | types = require('../types') 20 | 21 | mockCache = fromJS({ 22 | one: { 23 | two: 3, 24 | }, 25 | }) 26 | mockState = { 27 | [queries.CACHE_KEY]: mockCache, 28 | testing: 'omg', 29 | } 30 | }) 31 | 32 | it('gets the cache from store state', () => { 33 | const {getCache} = queries 34 | expect( 35 | getCache(mockState).equals(mockCache) 36 | ).toBe(true) 37 | }) 38 | 39 | it('flattens queries', () => { 40 | let {flatten} = queries 41 | let mockFn = () => {} 42 | let mockQuery = fromJS({one: {two: mockFn}}) 43 | expect( 44 | flatten(mockQuery).toArray() 45 | ).toEqual([ 46 | [['one', 'two'], mockFn], 47 | ]) 48 | }) 49 | 50 | it('correctly checks if a query isLoading', () => { 51 | let {isLoading, resolve} = queries 52 | let {value} = resolvers 53 | expect(isLoading( 54 | resolve( 55 | () => ({one: {two: value}}), 56 | mockState 57 | ) 58 | )).toEqual(false) 59 | expect(isLoading( 60 | resolve( 61 | () => ({three: {four: value}}), 62 | mockState 63 | ) 64 | )).toEqual(true) 65 | }) 66 | 67 | it('hydrates a query funtion', () => { 68 | let {hydrate} = queries 69 | let {value} = resolvers 70 | let query = ({testing}, {myProp}) => ({ 71 | [testing]: {wow: value}, 72 | [myProp]: {frosted: value}, 73 | }) 74 | expect( 75 | hydrate(query, mockState, {myProp: 'butts'}).toArray() 76 | ).toEqual([ 77 | [['omg', 'wow'], value], 78 | [['butts', 'frosted'], value], 79 | ]) 80 | }) 81 | 82 | it('resolves a query against a cache', () => { 83 | let {resolve} = queries 84 | let {value} = resolvers 85 | let query = () => ({one: {two: value}}) 86 | expect(resolve(query, mockState).toJS()).toEqual({ 87 | one: { 88 | two: 3, 89 | }, 90 | }) 91 | }) 92 | 93 | it('returns UNRESOLVED for uncached paths', () => { 94 | let {resolve} = queries 95 | let {value} = resolvers 96 | let {UNRESOLVED} = types 97 | let query = () => ({one: {other: value}}) 98 | expect(resolve(query, mockState).toJS()).toEqual({ 99 | one: { 100 | other: UNRESOLVED.toJS(), 101 | }, 102 | }) 103 | }) 104 | 105 | it('extracts subscriptions', () => { 106 | let {subscriptions} = queries 107 | let {value} = resolvers 108 | let query = () => ({ 109 | one: {two: value}, 110 | three: { 111 | four: {five: value}, 112 | }, 113 | }) 114 | expect(subscriptions(query).toJS()).toEqual([ 115 | ['one', 'two'], 116 | ['three', 'four', 'five'], 117 | ]) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/__tests__/resolvers-test.js: -------------------------------------------------------------------------------- 1 | jest 2 | .dontMock('../resolvers') 3 | .dontMock('../queries') 4 | 5 | import { fromJS } from 'immutable' 6 | 7 | describe('resolvers', () => { 8 | let resolvers 9 | 10 | beforeEach(() => { 11 | resolvers = require('../resolvers') 12 | }) 13 | 14 | it('retrieves a value', () => { 15 | const {value} = resolvers 16 | const mockVal = 'testing-wow' 17 | expect(value(mockVal)).toBe(mockVal) 18 | }) 19 | 20 | it('constructs a proper get query', () => { 21 | const {get, value} = resolvers 22 | expect(get('testing')).toEqual({ 23 | testing: value, 24 | }) 25 | }) 26 | 27 | it('constructs a valid getIn query', () => { 28 | const {getIn, value} = resolvers 29 | expect(getIn('one', 'two', 'three')).toEqual({ 30 | one: { 31 | two: { 32 | three: value, 33 | }, 34 | }, 35 | }) 36 | }) 37 | 38 | it('constructs a valid index query', () => { 39 | const {index} = resolvers 40 | const indexResolver = index(['users']) 41 | const mockCache = fromJS({ 42 | users: { 43 | '123': { 44 | name: 'one', 45 | friends: { 46 | '456': true, 47 | '789': true, 48 | }, 49 | }, 50 | '456': { name: 'two' }, 51 | '789': { name: 'three' }, 52 | }, 53 | }) 54 | const mockState = { firebase: mockCache } 55 | const value = mockCache.getIn(['users', '123', 'friends']) 56 | expect( 57 | indexResolver.subscriptions(value) 58 | ).toEqual([ 59 | ['users', '456'], 60 | ['users', '789'], 61 | ]) 62 | expect( 63 | indexResolver.values(value, mockState, {}).toJS() 64 | ).toEqual({ 65 | '456': { name: 'two' }, 66 | '789': { name: 'three' }, 67 | }) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/createFirebaseMiddleWare.js: -------------------------------------------------------------------------------- 1 | import { PATHS_SUBSCRIBED, PATHS_UNSUBSCRIBED } from './ActionTypes' 2 | import debounce from 'debounce' 3 | import { sync } from './ActionCreators' 4 | import { fromJS, Map } from 'immutable' 5 | import { partial } from 'ramda' 6 | import SubscriptionRecord from './SubscriptionRecord' 7 | 8 | function toStringPath(arrayPath) { 9 | return arrayPath.join('/') 10 | } 11 | 12 | function subscribe( 13 | firebaseInstance, 14 | subscriptions, 15 | sid, 16 | path, 17 | handleUpdate, 18 | handleError 19 | ) { 20 | if (subscriptions.has(path)) { 21 | return subscriptions.updateIn( 22 | [path, 'subscribers'], 23 | subs => subs.add(sid) 24 | ) 25 | } 26 | firebaseInstance 27 | .child(path) 28 | .on('value', handleUpdate, handleError) 29 | return subscriptions.set( 30 | path, 31 | SubscriptionRecord() 32 | .set('handler', handleUpdate) 33 | .updateIn(['subscribers'], subs => subs.add(sid)) 34 | ) 35 | } 36 | 37 | function unsubscribe(subscriptions, sid, path) { 38 | if (subscriptions.getIn([path, 'subscribers']).size > 1) { 39 | return subscriptions.updateIn( 40 | [path, 'subscribers'], 41 | subs => subs.remove(sid) 42 | ) 43 | } 44 | return subscriptions.delete(path) 45 | } 46 | 47 | export default function createFirebaseMiddleware(firebaseInstance) { 48 | let queue = Map() 49 | let subscriptions = Map() 50 | let syncUpdates = debounce((store) => { 51 | store.dispatch(sync(queue)) 52 | queue = Map() 53 | }) 54 | let queueUpdate = (store, stringPath, snapshot) => { 55 | queue = queue.set( 56 | stringPath, 57 | fromJS(snapshot.val()) 58 | ) 59 | syncUpdates(store) 60 | } 61 | let queueError = (store, stringPath, error) => { 62 | if (__DEV__) { 63 | console.error(`Error at \`${stringPath}\`: ${error.message}`) 64 | } 65 | queue = queue.set( 66 | stringPath, 67 | undefined 68 | ) 69 | syncUpdates(store) 70 | } 71 | return store => next => action => { 72 | if (action.type === PATHS_SUBSCRIBED) { 73 | action.payload.paths.forEach(path => { 74 | let stringPath = toStringPath(path) 75 | subscriptions = subscribe( 76 | firebaseInstance, 77 | subscriptions, 78 | action.payload.sid, 79 | stringPath, 80 | partial(queueUpdate, [store, stringPath]), 81 | partial(queueError, [store, stringPath]) 82 | ) 83 | }) 84 | } 85 | if (action.type === PATHS_UNSUBSCRIBED) { 86 | action.payload.paths.forEach(path => { 87 | subscriptions = unsubscribe( 88 | subscriptions, 89 | action.payload.sid, 90 | toStringPath(path) 91 | ) 92 | }) 93 | } 94 | return next(action) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/createFirebaseReducers.js: -------------------------------------------------------------------------------- 1 | import { PATHS_SYNCED } from './ActionTypes' 2 | import { Iterable, List, Map } from 'immutable' 3 | import { handleActions } from 'redux-actions' 4 | import { Meta } from './types' 5 | 6 | const emptyMeta = Meta() 7 | function defineMeta(path, value) { 8 | if (!Iterable.isIterable(value)) { 9 | return value 10 | } 11 | Object.defineProperty(value, 'meta', { 12 | value: emptyMeta.merge({ 13 | id: path.last(), 14 | path, 15 | }), 16 | }) 17 | value.forEach( 18 | (child, childKey) => defineMeta(path.push(childKey), child) 19 | ) 20 | return value 21 | } 22 | 23 | function toArrayPath(strPath) { 24 | return strPath.split('/') 25 | } 26 | 27 | export default function createFirebaseReducer(initialState = Map()) { 28 | return handleActions({ 29 | [PATHS_SYNCED]: (state, {payload}) => { 30 | return payload.reduce( 31 | (state, value, path) => { 32 | let arrayPath = toArrayPath(path) 33 | return state.setIn( 34 | arrayPath, 35 | defineMeta(List(arrayPath), value) 36 | ) 37 | }, 38 | state 39 | ) 40 | }, 41 | }, initialState) 42 | } 43 | -------------------------------------------------------------------------------- /src/fetch.js: -------------------------------------------------------------------------------- 1 | import { 2 | subscribe, 3 | unsubscribe, 4 | } from './ActionCreators' 5 | import { 6 | isLoading, 7 | resolve, 8 | subscriptions, 9 | } from './queries' 10 | import { partial } from 'ramda' 11 | import React, { PropTypes } from 'react' 12 | import { connect as connectRedux } from 'react-redux' 13 | 14 | let ids = 0 15 | function nextSID() { 16 | return 'hoc_subscriber_' + ++ids 17 | } 18 | 19 | function select(query, rootState, rootProps) { 20 | return { 21 | firebaseResult: resolve(query, rootState, rootProps), 22 | rootProps, 23 | rootState, 24 | } 25 | } 26 | 27 | export default defaultLoadingComponent => query => (Component, LoadingComponent = defaultLoadingComponent) => { 28 | return connectRedux( 29 | partial(select, [query]) 30 | )( 31 | React.createClass({ 32 | displayName: `Firebase(${Component.displayName})`, 33 | 34 | propTypes: { 35 | dispatch: PropTypes.func.isRequired, 36 | firebaseResult: PropTypes.object, 37 | rootProps: PropTypes.object, 38 | rootState: PropTypes.object, 39 | }, 40 | 41 | componentWillMount() { 42 | this.updateSubscriptions(this.props) 43 | }, 44 | 45 | componentWillReceiveProps(nextProps) { 46 | this.updateSubscriptions(nextProps) 47 | }, 48 | 49 | componentWillUnmount() { 50 | const {dispatch} = this.props 51 | dispatch( 52 | unsubscribe( 53 | this._sid, 54 | this._lastSubscriptions.toJS() 55 | ) 56 | ) 57 | }, 58 | 59 | render() { 60 | if (isLoading(this.props.firebaseResult)) { 61 | return LoadingComponent ? : null 62 | } 63 | const { firebaseResult, rootProps } = this.props 64 | return ( 65 | 69 | ) 70 | }, 71 | 72 | updateSubscriptions({dispatch, rootProps, rootState}) { 73 | if (!this._sid) { 74 | this._sid = nextSID() 75 | } 76 | const newSubscriptions = subscriptions(query, rootState, rootProps) 77 | if (newSubscriptions.equals(this._lastSubscriptions)) { 78 | return 79 | } 80 | this._lastSubscriptions = newSubscriptions 81 | dispatch( 82 | subscribe( 83 | this._sid, 84 | this._lastSubscriptions.toJS() 85 | ) 86 | ) 87 | }, 88 | }) 89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import createFirebaseMiddleware from './createFirebaseMiddleware' 2 | import createFirebaseReducers from './createFirebaseReducers' 3 | import fetch from './fetch' 4 | import * as resolvers from './resolvers' 5 | import operations from './operations' 6 | 7 | export default { 8 | createFirebaseMiddleware, 9 | createFirebaseReducers, 10 | fetch, 11 | operations, 12 | resolvers, 13 | } 14 | -------------------------------------------------------------------------------- /src/operations.js: -------------------------------------------------------------------------------- 1 | import Firebase from 'firebase' 2 | import { fromJS } from 'immutable' 3 | import { partial } from 'ramda' 4 | 5 | export const instance = new Firebase(__FIREBASE__) 6 | 7 | function promiseCall(method, args) { 8 | return new Promise((resolve, reject) => { 9 | instance[method](args, (error, data) => { 10 | if (error) { 11 | reject(error) 12 | } else { 13 | resolve(data) 14 | } 15 | }) 16 | }) 17 | } 18 | 19 | export const authWithPassword = partial(promiseCall, ['authWithPassword']) 20 | export const changeEmail = partial(promiseCall, ['changeEmail']) 21 | export const changePassword = partial(promiseCall, ['changePassword']) 22 | export const createUser = partial(promiseCall, ['createUser']) 23 | export const removeUser = partial(promiseCall, ['removeUser']) 24 | export const resetPassword = partial(promiseCall, ['resetPassword']) 25 | 26 | export function getAuth() { 27 | return instance.getAuth() 28 | } 29 | 30 | function serializeData(data) { 31 | if (!data || typeof data.toJS !== 'function') { 32 | return data 33 | } 34 | return data.toJS() 35 | } 36 | 37 | function baseWrite(operation, keyPath, data) { 38 | const ref = instance.child(keyPath.join('/')) 39 | return new Promise((resolve, reject) => { 40 | const newRef = ref[operation](serializeData(data), err => { 41 | if (err) { 42 | reject(err) 43 | } else { 44 | resolve([data, (newRef || ref).path.slice()]) 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | export const setIn = partial(baseWrite, ['set']) 51 | 52 | export function deleteIn(keyPath) { 53 | return setIn(keyPath, null) 54 | } 55 | 56 | export const pushIn = partial(baseWrite, ['push']) 57 | 58 | export function subscribe(keyPath, handler) { 59 | return instance.child(keyPath.join('/')).on('value', handler) 60 | } 61 | 62 | export function transactionIn(keyPath, operation) { 63 | const ref = instance.child(keyPath.join('/')) 64 | return new Promise((resolve, reject) => { 65 | ref.transaction(currentData => { 66 | return serializeData( 67 | operation( 68 | fromJS(currentData) 69 | ) 70 | ) 71 | }, (error, committed, snapshot) => { 72 | if (error) { 73 | reject(error) 74 | } else if (!committed) { 75 | reject(null) 76 | } else { 77 | resolve([fromJS(snapshot.val()), ref.path.n]) 78 | } 79 | }) 80 | }) 81 | } 82 | 83 | export const updateIn = partial(baseWrite, ['update']) 84 | -------------------------------------------------------------------------------- /src/queries.js: -------------------------------------------------------------------------------- 1 | import { fromJS, Iterable, List, Map, Set } from 'immutable' 2 | import { value } from './resolvers' 3 | import { 4 | isResolver, 5 | isUnresolved, 6 | UNRESOLVED, 7 | } from './types' 8 | 9 | export const CACHE_KEY = 'firebase' 10 | 11 | export function getCache(state) { 12 | return state[CACHE_KEY] || Map() 13 | } 14 | 15 | export function flatten(child, cache) { 16 | return Map().withMutations(resolvers => { 17 | function setResolvers(keyPath, child) { 18 | if (child !== value && typeof child === 'function') { 19 | if (!cache.hasIn(keyPath)) { 20 | resolvers.set(keyPath, value) 21 | return 22 | } 23 | setResolvers(keyPath, child(cache.getIn(keyPath))) 24 | return 25 | } 26 | if (child === value || isResolver(child)) { 27 | resolvers.set(keyPath, child) 28 | return 29 | } 30 | if (Map.isMap(child)) { 31 | child.forEach( 32 | (grandChild, key) => setResolvers(keyPath.push(key), grandChild) 33 | ) 34 | return 35 | } 36 | console.warn( 37 | 'flatten: invalid value `%s` at `%s`', 38 | child, 39 | keyPath 40 | ) 41 | } 42 | child.forEach((val, key) => setResolvers(List.of(key), val)) 43 | }) 44 | } 45 | 46 | export function hydrate(query, state, props) { 47 | return flatten( 48 | fromJS( 49 | query(state, props) 50 | ), 51 | getCache(state) 52 | ) 53 | } 54 | 55 | export function isLoading(results) { 56 | if (isUnresolved(results)) { 57 | return true 58 | } 59 | if (Iterable.isIterable(results)) { 60 | return results.some(isLoading) 61 | } 62 | return false 63 | } 64 | 65 | export function resolve(query, state, props = {}) { 66 | const cache = getCache(state) 67 | return hydrate(query, state, props).reduce( 68 | (results, resolver, keyPath) => { 69 | if (!cache.hasIn(keyPath)) { 70 | return results.setIn(keyPath, UNRESOLVED) 71 | } 72 | if (typeof resolver === 'function') { 73 | return results.setIn( 74 | keyPath, 75 | resolver(cache.getIn(keyPath), state, props) 76 | ) 77 | } 78 | if (isResolver(resolver)) { 79 | return results.setIn( 80 | keyPath, 81 | resolver.resolve( 82 | cache.getIn(keyPath), 83 | cache 84 | ) 85 | ) 86 | } 87 | console.warn( 88 | 'resolve: invalid value `%s` at `%s`', 89 | resolver, 90 | keyPath 91 | ) 92 | }, 93 | Map() 94 | ) 95 | } 96 | 97 | export function subscriptions(query, state, props) { 98 | const cache = getCache(state) 99 | return hydrate(query, state, props) 100 | .reduce((subs, child, keyPath) => { 101 | if (!isResolver(child)) { 102 | return subs.add(keyPath) 103 | } 104 | return subs.union( 105 | child.subscriptions( 106 | cache.getIn(keyPath) || UNRESOLVED, 107 | keyPath, 108 | cache 109 | ) 110 | ) 111 | }, Set()) 112 | } 113 | -------------------------------------------------------------------------------- /src/resolvers.js: -------------------------------------------------------------------------------- 1 | import { fromJS, List, OrderedMap, Set } from 'immutable' 2 | import { isUnresolved, Resolver, UNRESOLVED } from './types' 3 | 4 | const emptyResolver = Resolver() 5 | 6 | function cget(cache, path) { 7 | if (cache.hasIn(path)) { 8 | return cache.getIn(path) 9 | } 10 | return UNRESOLVED 11 | } 12 | 13 | export function byIndex(pathToIndex) { 14 | pathToIndex = List(pathToIndex) 15 | return emptyResolver.merge({ 16 | subscriptions(_, keyPath, cache) { 17 | const indexVal = cget(cache, pathToIndex) 18 | if (isUnresolved(indexVal)) { 19 | return [pathToIndex] 20 | } 21 | return indexVal ? indexVal.keySeq() 22 | .map(id => keyPath.push(id)) 23 | .concat([pathToIndex]) 24 | .toSet() : Set() 25 | }, 26 | resolve(allItems, cache) { 27 | const indexVal = cget(cache, pathToIndex) 28 | if (isUnresolved(indexVal)) { 29 | return UNRESOLVED 30 | } 31 | return indexVal ? indexVal.map((_, id) => { 32 | let res = cget(allItems, [id]) 33 | if (!res) { 34 | return res 35 | } 36 | return res.set('_path', pathToIndex.push(id)) 37 | }) : OrderedMap() 38 | }, 39 | }) 40 | } 41 | 42 | export function value(val) { 43 | return val 44 | } 45 | 46 | export function get(key) { 47 | return { 48 | [key]: value, 49 | } 50 | } 51 | 52 | export function getIn(head, ...tail) { 53 | if (!tail.length) { 54 | return get(head) 55 | } 56 | return { 57 | [head]: getIn(...tail), 58 | } 59 | } 60 | 61 | export const each = resolver => val => { 62 | if (isUnresolved(val)) { 63 | return val 64 | } 65 | return val ? val.map(() => fromJS(resolver)) : OrderedMap() 66 | } 67 | 68 | export function index(pathTo) { 69 | pathTo = List(pathTo) 70 | return emptyResolver.merge({ 71 | subscriptions(val, keyPath) { 72 | if (isUnresolved(val)) { 73 | return [keyPath] 74 | } 75 | return val.keySeq() 76 | .map(id => pathTo.push(id)) 77 | .concat([keyPath]) 78 | .toSet() 79 | }, 80 | resolve(indexMap, cache) { 81 | return indexMap ? 82 | indexMap.map((_, id) => cget(cache, pathTo.push(id))) : 83 | OrderedMap() 84 | }, 85 | }) 86 | } 87 | 88 | export function key(pathTo) { 89 | pathTo = List(pathTo) 90 | return emptyResolver.merge({ 91 | subscriptions(id, keyPath) { 92 | if (isUnresolved(id)) { 93 | return [keyPath] 94 | } 95 | return [keyPath, pathTo.push(id)] 96 | }, 97 | resolve(id, cache) { 98 | const path = pathTo.push(id) 99 | return cget(cache, path).set('_path', path) 100 | }, 101 | }) 102 | } 103 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | import { Record } from 'immutable' 2 | 3 | export const Meta = Record({ 4 | id: '', 5 | path: [], 6 | }) 7 | 8 | export const Resolver = Record({ 9 | subscriptions() {}, 10 | resolve() {}, 11 | }) 12 | 13 | export function isResolver(val) { 14 | return val instanceof Resolver 15 | } 16 | 17 | export const UNRESOLVED = Record({ 18 | isLoading: true, 19 | _path: null, 20 | })() 21 | 22 | export function isUnresolved(val) { 23 | return UNRESOLVED.equals(val) 24 | } 25 | --------------------------------------------------------------------------------