From e72d80b28f47b61a342f5e918f8721fcc16e736f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 15:20:01 +0100 Subject: [PATCH 001/136] Treat core decorator as plugin --- src/builder.js | 64 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 9 deletions(-) diff --git a/src/builder.js b/src/builder.js index 1326b27..6da7e9d 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,4 +1,4 @@ -import {mapObject, mapValues, compose, map, toObject, +import {mapObject, mapValues, compose, map, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {createEntityStore} from './entity-store'; import {createQueryCache} from './query-cache'; @@ -10,8 +10,47 @@ const toEntity = ([name, c]) => ({ ...c }); -// [Entity] -> Api -const toApi = compose(mapValues(prop('api')), toObject(prop('name'))); +const KNOWN_STATICS = { + name: true, + length: true, + prototype: true, + caller: true, + arguments: true, + arity: true +}; + +const hoistMetaData = (a, b) => { + const keys = Object.getOwnPropertyNames(a); + for (let i = keys.length - 1; i >= 0; i--) { + const k = keys[i]; + if (!KNOWN_STATICS[k]) { + b[k] = a[k]; + } + } + return b; +}; + +const hoistApiMetaData = (configs, nextConfigs) => { + return mapValues((entity) => { + return { + ...entity, + api: reduce((apiM, [fnName, fn]) => { + const getFn = compose(prop(fnName), prop('api'), prop(entity.name)); + apiM[fnName] = hoistMetaData(getFn(configs), fn); + return apiM; + }, {}, toPairs(entity.api)) + }; + }, nextConfigs); +}; + +const applyPlugin = curry((config, entityConfigs, plugin) => { + return hoistApiMetaData(entityConfigs, plugin(config, entityConfigs)); +}); + +const stripMetaData = (fn) => (...args) => fn(...args); + +// EntityConfig -> Api +const toApi = mapValues(compose(mapValues(stripMetaData), prop('api'))); // EntityConfig -> EntityConfig const setEntityConfigDefaults = ec => { @@ -56,14 +95,21 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -// Config -> Api -export const build = (c) => { - const config = c.__config || {idField: 'id'}; - const entityConfigs = getEntityConfigs(c); +const corePlugin = (config, entityConfigs) => { const entities = mapObject(toEntity, entityConfigs); const entityStore = createEntityStore(entities); const queryCache = createQueryCache(entityStore); - const createApi = compose(toApi, map(decorate(config, entityStore, queryCache))); + return compose( + toObject(prop('name')), + map(decorate(config, entityStore, queryCache)) + )(entities); +}; - return createApi(entities); +// Config -> Api +export const build = (c, ps = []) => { + const config = c.__config || {idField: 'id'}; + const entityConfigs = getEntityConfigs(c); + const plugins = [corePlugin, ...ps]; + const createApi = compose(toApi, reduce(applyPlugin(config), entityConfigs)); + return createApi(plugins); }; From 6a593e0082fde7debff58feb1931095a0b063b4b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 17:34:04 +0100 Subject: [PATCH 002/136] Try to limit plugin responsibilities --- src/builder.js | 48 ++++++++++++++++++++++-------------------- src/decorator/index.js | 16 +++++++++++++- src/fp.js | 3 +++ 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/src/builder.js b/src/builder.js index 6da7e9d..db64e7f 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,8 +1,8 @@ -import {mapObject, mapValues, compose, map, toObject, reduce, toPairs, - prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; +import {map, mapObject, mapValues, values, compose, toObject, reduce, toPairs, + flip, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {createEntityStore} from './entity-store'; import {createQueryCache} from './query-cache'; -import {decorate} from './decorator'; +import {decorate2} from './decorator'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -30,23 +30,24 @@ const hoistMetaData = (a, b) => { return b; }; -const hoistApiMetaData = (configs, nextConfigs) => { +export const mapApiFunctions = (fn, entityConfigs) => { return mapValues((entity) => { return { ...entity, - api: reduce((apiM, [fnName, fn]) => { - const getFn = compose(prop(fnName), prop('api'), prop(entity.name)); - apiM[fnName] = hoistMetaData(getFn(configs), fn); - return apiM; - }, {}, toPairs(entity.api)) + api: reduce( + (apiM, [apiFnName, apiFn]) => { + const getFn = compose(prop(apiFnName), prop('api')); + const nextFn = fn(entityConfigs, entity, apiFnName, apiFn); + apiM[apiFnName] = hoistMetaData(getFn(entity), nextFn); + return apiM; + }, + {}, + toPairs(entity.api) + ) }; - }, nextConfigs); + }, entityConfigs); }; -const applyPlugin = curry((config, entityConfigs, plugin) => { - return hoistApiMetaData(entityConfigs, plugin(config, entityConfigs)); -}); - const stripMetaData = (fn) => (...args) => fn(...args); // EntityConfig -> Api @@ -90,26 +91,27 @@ const setApiConfigDefaults = ec => { // Config -> Map String EntityConfig const getEntityConfigs = compose( + toObject(prop('name')), + mapObject(toEntity), mapValues(setApiConfigDefaults), mapValues(setEntityConfigDefaults), filterObject(compose(not, isEqual('__config'))) ); -const corePlugin = (config, entityConfigs) => { - const entities = mapObject(toEntity, entityConfigs); - const entityStore = createEntityStore(entities); +const createCorePlugin = (config, entityConfigs) => { + const entityStore = compose(createEntityStore, values)(entityConfigs); const queryCache = createQueryCache(entityStore); - return compose( - toObject(prop('name')), - map(decorate(config, entityStore, queryCache)) - )(entities); + return decorate2(entityStore, queryCache, config); }; // Config -> Api export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const entityConfigs = getEntityConfigs(c); - const plugins = [corePlugin, ...ps]; - const createApi = compose(toApi, reduce(applyPlugin(config), entityConfigs)); + const plugins = [ + createCorePlugin(config, entityConfigs), + ...map((p) => curry(p)(config), ps) + ]; + const createApi = compose(toApi, reduce(flip(mapApiFunctions), entityConfigs)); return createApi(plugins); }; diff --git a/src/decorator/index.js b/src/decorator/index.js index 4d811d7..7512887 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -5,7 +5,7 @@ import {decorateUpdate} from './update'; import {decorateDelete} from './delete'; import {decorateNoOperation} from './no-operation'; -const decorateApi = curry((config, entityStore, queryCache, entity, apiFn) => { +export const decorateApi = curry((config, entityStore, queryCache, entity, apiFn) => { const handler = { CREATE: decorateCreate, READ: decorateRead, @@ -26,3 +26,17 @@ export const decorate = curry((config, entityStore, queryCache, entity) => { api: decoratedApi }; }); + +export const decorate2 = curry( + (entityStore, queryCache, config, entityConfigs, entity, apiFnName, apiFn) => { + const handler = { + CREATE: decorateCreate, + READ: decorateRead, + UPDATE: decorateUpdate, + DELETE: decorateDelete, + NO_OPERATION: decorateNoOperation + }[apiFn.operation]; + return handler(config, entityStore, queryCache, entity, apiFn); + } +); + diff --git a/src/fp.js b/src/fp.js index d62296f..0a236e7 100644 --- a/src/fp.js +++ b/src/fp.js @@ -101,6 +101,9 @@ export const mapValues = curry((fn, o) => { }, {}, keys); }); +// Object -> [b] +export const values = mapObject((pair) => pair[1]); + // (a -> b) -> [Object] -> Object export const toObject = curry((getK, xs) => reduce( (m, x) => writeToObject(m, getK(x), x), From 5cb287c2aeb86226818b6e14dd53cf1ca4549046 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 17:43:13 +0100 Subject: [PATCH 003/136] Add test to showcase plugin usage --- src/builder.spec.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/builder.spec.js b/src/builder.spec.js index edc7cf8..fb4d53b 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -126,4 +126,24 @@ describe('builder', () => { .then(() => api.user.getUsers()) .then(expectOnlyOneApiCall); }); + + it('takes plugins as second argument', (done) => { + const myConfig = config(); + const pluginTracker = {}; + const plugin = (pConfig) => { + const pName = pConfig.name; + pluginTracker[pName] = {}; + return (c, entityConfigs, entity, apiFnName, apiFn) => { + pluginTracker[pName][apiFnName] = true; + return apiFn; + }; + }; + const pluginName = 'X'; + const expectACall = () => expect(pluginTracker[pluginName].getUsers).to.be.true; + + const api = build(myConfig, [plugin({ name: pluginName })]); + api.user.getUsers() + .then(expectACall) + .then(() => done()); + }); }); From 60940fd1c5958161df6eb97d399af2e20a6196a3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 18:09:57 +0100 Subject: [PATCH 004/136] Add test for main functionality --- src/denormalization/index.js | 0 src/denormalization/index.spec.js | 70 +++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/denormalization/index.js create mode 100644 src/denormalization/index.spec.js diff --git a/src/denormalization/index.js b/src/denormalization/index.js new file mode 100644 index 0000000..e69de29 diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js new file mode 100644 index 0000000..31e806b --- /dev/null +++ b/src/denormalization/index.spec.js @@ -0,0 +1,70 @@ +import { build } from '../builder'; +import denormalizer from '.'; +import { curry, prop, toObject, values } from '../fp'; + +const toIdMap = toObject(prop('id')); + +const peter = { id: 'peter' }; +const gernot = { id: 'gernot' }; + +const users = toIdMap([peter, gernot]); + +const m1 = { id: 'x', author: peter.id, recipient: gernot.id }; +const m2 = { id: 'y', author: gernot.id, recipient: peter.id }; + +const messages = toIdMap([m1, m2]); + +const getById = curry((m, id) => Promise.resolve(m[id])); +const getAll = (m) => () => Promise.resolve(values(m)); + +const getUser = getById(users); +getUser.operation = 'READ'; +getUser.byId = true; +const getUsers = getAll(users); +getUser.operation = 'READ'; + +const getMessage = getById(messages); +getMessage.operation = 'READ'; +getMessage.byId = true; +const getMessages = getAll(messages); +getMessages.operation = 'READ'; + + +const config = () => ({ + user: { + api: { getUser, getUsers }, + plugins: { + denormalizer: { + getOne: 'getUser', + getAll: 'getUsers', + threshold: 5 + } + } + }, + message: { + api: { getMessage, getMessages }, + plugins: { + denormalizer: { + schema: { + author: 'user', + recipient: 'user' + } + } + } + } +}); + +const expectResolved = curry((k, val, obj) => { + expect(obj[k]).to.deep.equal(val); + return obj; +}); + +describe('with a fn, that returns one object', () => { + it('resolves all references', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessage(m1.id) + .then(expectResolved('author', users[m1.author])) + .then(expectResolved('recipient', users[m1.recipient])) + .then(() => done()); + }); +}); From c4cf1e73b0603773981652969ebe62eaff121156 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 18:17:31 +0100 Subject: [PATCH 005/136] Update spec --- src/denormalization/index.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js index 31e806b..92e26d1 100644 --- a/src/denormalization/index.spec.js +++ b/src/denormalization/index.spec.js @@ -1,5 +1,5 @@ import { build } from '../builder'; -import denormalizer from '.'; +import { denormalizer } from '.'; import { curry, prop, toObject, values } from '../fp'; const toIdMap = toObject(prop('id')); From 976a12ad64854045042b8a74739d0a4a26437992 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 19:11:35 +0100 Subject: [PATCH 006/136] Implement first version of denormalization --- src/denormalization/index.js | 95 ++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/src/denormalization/index.js b/src/denormalization/index.js index e69de29..e801854 100644 --- a/src/denormalization/index.js +++ b/src/denormalization/index.js @@ -0,0 +1,95 @@ +import { compose, curry, head, map, mapValues, prop, reduce, fromPairs, toPairs, toObject } from '../fp'; + +export const NAME = 'denormalizer'; + +const toIdMap = toObject(prop('id')); + +const getPluginConf = curry((configs, entityName) => compose( + prop(NAME), + prop('plugins'), + prop(entityName) +)(configs)); + +const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); + +const getSchema = compose(prop('schema'), getPluginConf); + +const collectTargets = curry((schema, res, item) => { + // TODO need to traverse the schema all the way down, in case they are nested + const keys = Object.keys(schema); + return reduce((m, k) => { + const type = schema[k]; + let list = m[type]; + if (!list) { list = []; } + const val = item[k]; + // need to make sure we pass only unique values! + if (Array.isArray(val)) { + list = list.concat(val); + } else { + list.push(val); + } + m[type] = list; + return m; + }, res, keys); +}); + +const resolveItem = curry((schema, entities, item) => { + // reuse traversal function + const keys = Object.keys(schema); + return reduce((m, k) => { + const type = schema[k]; + const getById = (id) => entities[type][id]; + const val = item[k]; + const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); + return { ...m, [k]: resolvedVal }; + }, item, keys); +}); + +const resolveItems = curry((schema, items, entities) => { + return map(resolveItem(schema, entities), items); +}); + +const requestEntities = curry((config, api, ids) => { + const fromApi = (p) => api[config[p]]; + const getOne = fromApi('getOne'); + const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); + const getAll = fromApi('getAll') || (() => getSome(ids)); + const threshold = config.threshold || 0; + + const noOfItems = ids.length; + + if (noOfItems === 1) { + return getOne(ids[0]).then((e) => [e]); + } + if (noOfItems > threshold) { + return getAll(); + } + return getSome(ids); +}); + +const resolve = curry((entityConfigs, schema, items) => { + const requestsToMake = compose(toPairs, reduce(collectTargets(schema), {}))(items); + return Promise.all(map(([t, ids]) => { + const conf = getPluginConf(entityConfigs, t); + const api = getApi(entityConfigs, t); + return requestEntities(conf, api, ids).then((es) => [t, es]); + }, requestsToMake)).then( + compose(resolveItems(schema, items), mapValues(toIdMap), fromPairs) + ); +}); + +export const denormalizer = () => (c, entityConfigs, entity, name, fn) => { + const schema = getSchema(entityConfigs, entity.name); + if (!schema) { + return fn; + } + return (...args) => { + return fn(...args).then((res) => { + const isArray = Array.isArray(res); + const items = isArray ? res : [res]; + + const resolved = resolve(entityConfigs, schema, items); + return isArray ? resolved : resolved.then(head); + }); + }; +}; From a667d48a092147d60d5b747df201aae69fcf90db Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 12 Mar 2017 22:45:31 +0100 Subject: [PATCH 007/136] Unify all plugins - including core --- src/builder.js | 22 +++++++++++----------- src/builder.spec.js | 14 +++++++------- src/denormalization/index.js | 4 ++-- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/builder.js b/src/builder.js index db64e7f..df282c5 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,5 +1,5 @@ import {map, mapObject, mapValues, values, compose, toObject, reduce, toPairs, - flip, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; + prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {createEntityStore} from './entity-store'; import {createQueryCache} from './query-cache'; import {decorate2} from './decorator'; @@ -37,7 +37,7 @@ export const mapApiFunctions = (fn, entityConfigs) => { api: reduce( (apiM, [apiFnName, apiFn]) => { const getFn = compose(prop(apiFnName), prop('api')); - const nextFn = fn(entityConfigs, entity, apiFnName, apiFn); + const nextFn = fn(entity, apiFnName, apiFn); apiM[apiFnName] = hoistMetaData(getFn(entity), nextFn); return apiM; }, @@ -98,20 +98,20 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -const createCorePlugin = (config, entityConfigs) => { +const corePlugin = (config, entityConfigs) => { const entityStore = compose(createEntityStore, values)(entityConfigs); const queryCache = createQueryCache(entityStore); - return decorate2(entityStore, queryCache, config); + return decorate2(entityStore, queryCache, config, entityConfigs); }; +const applyPlugin = curry((config, entityConfigs, plugin) => { + const pluginDecorator = plugin(config, entityConfigs); + return mapApiFunctions(pluginDecorator, entityConfigs); +}); + // Config -> Api export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; - const entityConfigs = getEntityConfigs(c); - const plugins = [ - createCorePlugin(config, entityConfigs), - ...map((p) => curry(p)(config), ps) - ]; - const createApi = compose(toApi, reduce(flip(mapApiFunctions), entityConfigs)); - return createApi(plugins); + const createApi = compose(toApi, reduce(applyPlugin(config), getEntityConfigs(c))); + return createApi([corePlugin, ...ps]); }; diff --git a/src/builder.spec.js b/src/builder.spec.js index fb4d53b..5bab826 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -2,6 +2,7 @@ import sinon from 'sinon'; import {build} from './builder'; +import {curry} from './fp'; const getUsers = () => Promise.resolve([{id: 1}, {id: 2}]); getUsers.operation = 'READ'; @@ -61,7 +62,7 @@ describe('builder', () => { myConfig.user.api.getUsers.idFrom = 'ARGS'; const api = build(myConfig); const start = Date.now(); - const checkTimeConstraint = () => { + const checkTimeConstraint = (xs) => { expect(Date.now() - start < 1000).to.be.true; done(); }; @@ -75,9 +76,7 @@ describe('builder', () => { it('Works with non default id set', (done) => { const myConfig = config(); myConfig.__config = {idField: 'mySecretId'}; - myConfig.user.api.getUsers = sinon.spy( - () => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}]) - ); + myConfig.user.api.getUsers = sinon.spy(() => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}])); myConfig.user.api.getUsers.operation = 'READ'; const api = build(myConfig); const expectOnlyOneApiCall = (xs) => { @@ -124,7 +123,8 @@ describe('builder', () => { .then(() => api.user.getUsers()) .then(delay) .then(() => api.user.getUsers()) - .then(expectOnlyOneApiCall); + .then(expectOnlyOneApiCall) + .catch(e => console.log(e)); }); it('takes plugins as second argument', (done) => { @@ -133,10 +133,10 @@ describe('builder', () => { const plugin = (pConfig) => { const pName = pConfig.name; pluginTracker[pName] = {}; - return (c, entityConfigs, entity, apiFnName, apiFn) => { + return curry((c, entityConfigs, entity, apiFnName, apiFn) => { pluginTracker[pName][apiFnName] = true; return apiFn; - }; + }); }; const pluginName = 'X'; const expectACall = () => expect(pluginTracker[pluginName].getUsers).to.be.true; diff --git a/src/denormalization/index.js b/src/denormalization/index.js index e801854..c38b7b0 100644 --- a/src/denormalization/index.js +++ b/src/denormalization/index.js @@ -78,7 +78,7 @@ const resolve = curry((entityConfigs, schema, items) => { ); }); -export const denormalizer = () => (c, entityConfigs, entity, name, fn) => { +export const denormalizer = curry((c, entityConfigs, entity, name, fn) => { const schema = getSchema(entityConfigs, entity.name); if (!schema) { return fn; @@ -92,4 +92,4 @@ export const denormalizer = () => (c, entityConfigs, entity, name, fn) => { return isArray ? resolved : resolved.then(head); }); }; -}; +}); From 215a74688f3a35e59a6d45b5e3a98901d0f0d437 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 07:45:27 +0100 Subject: [PATCH 008/136] Remove unused import --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index df282c5..61caac6 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,4 +1,4 @@ -import {map, mapObject, mapValues, values, compose, toObject, reduce, toPairs, +import {mapObject, mapValues, values, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {createEntityStore} from './entity-store'; import {createQueryCache} from './query-cache'; From 917cb5053734af1da88733758510ee8e9ab2d250 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 07:45:45 +0100 Subject: [PATCH 009/136] Add spec for resolution of lists --- src/denormalization/index.spec.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js index 92e26d1..af07484 100644 --- a/src/denormalization/index.spec.js +++ b/src/denormalization/index.spec.js @@ -6,11 +6,12 @@ const toIdMap = toObject(prop('id')); const peter = { id: 'peter' }; const gernot = { id: 'gernot' }; +const robin = { id: 'robin' }; -const users = toIdMap([peter, gernot]); +const users = toIdMap([peter, gernot, robin]); -const m1 = { id: 'x', author: peter.id, recipient: gernot.id }; -const m2 = { id: 'y', author: gernot.id, recipient: peter.id }; +const m1 = { id: 'x', author: peter.id, recipient: gernot.id, visibleTo: [robin.id] }; +const m2 = { id: 'y', author: gernot.id, recipient: peter.id, visibleTo: [] }; const messages = toIdMap([m1, m2]); @@ -47,7 +48,8 @@ const config = () => ({ denormalizer: { schema: { author: 'user', - recipient: 'user' + recipient: 'user', + visibleTo: ['user'] } } } @@ -60,11 +62,18 @@ const expectResolved = curry((k, val, obj) => { }); describe('with a fn, that returns one object', () => { - it('resolves all references', (done) => { + it('resolves references to simple id fields', (done) => { const api = build(config(), [denormalizer()]); api.message.getMessage(m1.id) .then(expectResolved('author', users[m1.author])) .then(expectResolved('recipient', users[m1.recipient])) .then(() => done()); }); + + it('resolves references to lists of ids', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessage(m1.id) + .then(expectResolved('visibleTo', [users[m1.visibleTo[0]]])) + .then(() => done()); + }); }); From d562e7dbe84a971a12065c1e6e8a6dbca6e767e9 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 07:46:31 +0100 Subject: [PATCH 010/136] Minor reshaping of spec --- src/denormalization/index.spec.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js index af07484..8a5bcc2 100644 --- a/src/denormalization/index.spec.js +++ b/src/denormalization/index.spec.js @@ -61,19 +61,22 @@ const expectResolved = curry((k, val, obj) => { return obj; }); -describe('with a fn, that returns one object', () => { - it('resolves references to simple id fields', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessage(m1.id) - .then(expectResolved('author', users[m1.author])) - .then(expectResolved('recipient', users[m1.recipient])) - .then(() => done()); - }); +describe('denormalizer', () => { + describe('with a fn, that returns one object', () => { + it('resolves references to simple id fields', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessage(m1.id) + .then(expectResolved('author', users[m1.author])) + .then(expectResolved('recipient', users[m1.recipient])) + .then(() => done()); + }); - it('resolves references to lists of ids', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessage(m1.id) - .then(expectResolved('visibleTo', [users[m1.visibleTo[0]]])) - .then(() => done()); + it('resolves references to lists of ids', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessage(m1.id) + .then(expectResolved('visibleTo', [users[m1.visibleTo[0]]])) + .then(() => done()); + }); }); }); + From e50fc4db1726e6dc73d14345877ff07320f0dff3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 07:51:38 +0100 Subject: [PATCH 011/136] Add spec to document working on lists as return value --- src/denormalization/index.spec.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js index 8a5bcc2..4289f87 100644 --- a/src/denormalization/index.spec.js +++ b/src/denormalization/index.spec.js @@ -1,6 +1,6 @@ import { build } from '../builder'; import { denormalizer } from '.'; -import { curry, prop, toObject, values } from '../fp'; +import { curry, prop, head, last, toObject, values } from '../fp'; const toIdMap = toObject(prop('id')); @@ -78,5 +78,22 @@ describe('denormalizer', () => { .then(() => done()); }); }); + + describe('with a fn, that returns a list of objects', () => { + it('resolves references to simple id fields', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessages() + .then((msgs) => { + const fst = head(msgs); + const snd = last(msgs); + expectResolved('author', users[m1.author])(fst); + expectResolved('recipient', users[m1.recipient])(fst); + + expectResolved('author', users[m2.author])(snd); + expectResolved('recipient', users[m2.recipient])(snd); + }) + .then(() => done()); + }); + }); }); From 211ab669f5b0c106b2d6cc818e8f40e6b186f4ad Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 08:21:24 +0100 Subject: [PATCH 012/136] Add spec for nested data - make specs not fail --- src/denormalization/index.js | 16 ++++++++--- src/denormalization/index.spec.js | 45 ++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/denormalization/index.js b/src/denormalization/index.js index c38b7b0..2cd85f1 100644 --- a/src/denormalization/index.js +++ b/src/denormalization/index.js @@ -23,12 +23,17 @@ const collectTargets = curry((schema, res, item) => { if (!list) { list = []; } const val = item[k]; // need to make sure we pass only unique values! - if (Array.isArray(val)) { - list = list.concat(val); + if (typeof val === 'object') { + // here we should traverse + if (Array.isArray(val)) { + list = list.concat(val); + } } else { list.push(val); } - m[type] = list; + if (list.length) { + m[type] = list; + } return m; }, res, keys); }); @@ -40,6 +45,11 @@ const resolveItem = curry((schema, entities, item) => { const type = schema[k]; const getById = (id) => entities[type][id]; const val = item[k]; + // typically a drill down would be needed here, we just return + // to make the original tests pass for now + if (typeof val === 'object' && !Array.isArray(val)) { + return m; + } const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); return { ...m, [k]: resolvedVal }; }, item, keys); diff --git a/src/denormalization/index.spec.js b/src/denormalization/index.spec.js index 4289f87..3d2616d 100644 --- a/src/denormalization/index.spec.js +++ b/src/denormalization/index.spec.js @@ -10,8 +10,29 @@ const robin = { id: 'robin' }; const users = toIdMap([peter, gernot, robin]); -const m1 = { id: 'x', author: peter.id, recipient: gernot.id, visibleTo: [robin.id] }; -const m2 = { id: 'y', author: gernot.id, recipient: peter.id, visibleTo: [] }; +const c1 = { id: 'a' }; +const c2 = { id: 'b' }; + +const comments = toIdMap([c1, c2]); + +const m1 = { + id: 'x', + author: peter.id, + recipient: gernot.id, + visibleTo: [robin.id], + nestedData: { + comments: [c1.id, c2.id] + } +}; +const m2 = { + id: 'y', + author: gernot.id, + recipient: peter.id, + visibleTo: [], + nestedData: { + comments: [] + } +}; const messages = toIdMap([m1, m2]); @@ -30,6 +51,12 @@ getMessage.byId = true; const getMessages = getAll(messages); getMessages.operation = 'READ'; +const getComment = getById(comments); +getComment.operation = 'READ'; +getComment.byId = true; +const getComments = getAll(comments); +getComments.operation = 'READ'; + const config = () => ({ user: { @@ -49,10 +76,22 @@ const config = () => ({ schema: { author: 'user', recipient: 'user', - visibleTo: ['user'] + visibleTo: ['user'], + nestedData: { + comments: ['comment'] + } } } } + }, + comment: { + api: { getComment, getComments }, + plugins: { + denormalizer: { + getOne: 'getComment', + getAll: 'getComments' + } + } } }); From 280c70451081030a422363a1fbb88907c2c3bf0e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 08:26:05 +0100 Subject: [PATCH 013/136] Add comment about next step --- src/denormalization/index.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/denormalization/index.js b/src/denormalization/index.js index 2cd85f1..a68b98a 100644 --- a/src/denormalization/index.js +++ b/src/denormalization/index.js @@ -88,6 +88,11 @@ const resolve = curry((entityConfigs, schema, items) => { ); }); +// TODO preprocess configuration. This has several benefits +// - We don't need traverse the config on the fly all the time +// - We can prepare a data structure which makes handling of nested data easy +// - We can validate if all necessary configuration is in place and fail fast if that's not the case + export const denormalizer = curry((c, entityConfigs, entity, name, fn) => { const schema = getSchema(entityConfigs, entity.name); if (!schema) { From c4c9d4b4a73b04ced192ac3076bc49af867c5109 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 20:30:24 +0100 Subject: [PATCH 014/136] Use obj instead of so many positional arguments in plugin api --- src/builder.js | 8 ++++---- src/builder.spec.js | 2 +- src/decorator/index.js | 27 +++++++++++++++------------ src/denormalization/index.js | 5 ++++- 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/builder.js b/src/builder.js index 61caac6..681aa95 100644 --- a/src/builder.js +++ b/src/builder.js @@ -37,7 +37,7 @@ export const mapApiFunctions = (fn, entityConfigs) => { api: reduce( (apiM, [apiFnName, apiFn]) => { const getFn = compose(prop(apiFnName), prop('api')); - const nextFn = fn(entity, apiFnName, apiFn); + const nextFn = fn({ entity, apiFnName, apiFn }); apiM[apiFnName] = hoistMetaData(getFn(entity), nextFn); return apiM; }, @@ -98,14 +98,14 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -const corePlugin = (config, entityConfigs) => { +const corePlugin = ({ config, entityConfigs }) => { const entityStore = compose(createEntityStore, values)(entityConfigs); const queryCache = createQueryCache(entityStore); - return decorate2(entityStore, queryCache, config, entityConfigs); + return decorate2(entityStore, queryCache, { config, entityConfigs }); }; const applyPlugin = curry((config, entityConfigs, plugin) => { - const pluginDecorator = plugin(config, entityConfigs); + const pluginDecorator = plugin({ config, entityConfigs }); return mapApiFunctions(pluginDecorator, entityConfigs); }); diff --git a/src/builder.spec.js b/src/builder.spec.js index 5bab826..85a45aa 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -133,7 +133,7 @@ describe('builder', () => { const plugin = (pConfig) => { const pName = pConfig.name; pluginTracker[pName] = {}; - return curry((c, entityConfigs, entity, apiFnName, apiFn) => { + return curry(({ config: c, entityConfigs }, { entity, apiFnName, apiFn }) => { pluginTracker[pName][apiFnName] = true; return apiFn; }); diff --git a/src/decorator/index.js b/src/decorator/index.js index 7512887..beeeb37 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -27,16 +27,19 @@ export const decorate = curry((config, entityStore, queryCache, entity) => { }; }); -export const decorate2 = curry( - (entityStore, queryCache, config, entityConfigs, entity, apiFnName, apiFn) => { - const handler = { - CREATE: decorateCreate, - READ: decorateRead, - UPDATE: decorateUpdate, - DELETE: decorateDelete, - NO_OPERATION: decorateNoOperation - }[apiFn.operation]; - return handler(config, entityStore, queryCache, entity, apiFn); - } -); +export const decorate2 = curry(( + entityStore, + queryCache, + { config, entityConfigs }, + { entity, apiFnName, apiFn } +) => { + const handler = { + CREATE: decorateCreate, + READ: decorateRead, + UPDATE: decorateUpdate, + DELETE: decorateDelete, + NO_OPERATION: decorateNoOperation + }[apiFn.operation]; + return handler(config, entityStore, queryCache, entity, apiFn); +}); diff --git a/src/denormalization/index.js b/src/denormalization/index.js index a68b98a..edb82cf 100644 --- a/src/denormalization/index.js +++ b/src/denormalization/index.js @@ -93,7 +93,10 @@ const resolve = curry((entityConfigs, schema, items) => { // - We can prepare a data structure which makes handling of nested data easy // - We can validate if all necessary configuration is in place and fail fast if that's not the case -export const denormalizer = curry((c, entityConfigs, entity, name, fn) => { +export const denormalizer = curry(( + { entityConfigs }, + { entity, apiFnName: name, apiFn: fn } +) => { const schema = getSchema(entityConfigs, entity.name); if (!schema) { return fn; From 3329b3ba8f7c099fb165e857acfd8a98f2802b57 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 20:31:18 +0100 Subject: [PATCH 015/136] Move denormalizer to own plugin folder --- src/{denormalization => plugins/denormalizer}/index.js | 0 src/{denormalization => plugins/denormalizer}/index.spec.js | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{denormalization => plugins/denormalizer}/index.js (100%) rename src/{denormalization => plugins/denormalizer}/index.spec.js (100%) diff --git a/src/denormalization/index.js b/src/plugins/denormalizer/index.js similarity index 100% rename from src/denormalization/index.js rename to src/plugins/denormalizer/index.js diff --git a/src/denormalization/index.spec.js b/src/plugins/denormalizer/index.spec.js similarity index 100% rename from src/denormalization/index.spec.js rename to src/plugins/denormalizer/index.spec.js From c1924ce356ed45c15008a58e3eb3bf695d60a6e6 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 20:37:37 +0100 Subject: [PATCH 016/136] Add get and set fp methods --- src/fp.js | 14 ++++++++++++++ src/fp.spec.js | 32 +++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/fp.js b/src/fp.js index 0a236e7..017366c 100644 --- a/src/fp.js +++ b/src/fp.js @@ -152,3 +152,17 @@ export const copyFunction = f => { } return newF; }; + +export const get = curry((props, o) => { + return reduce((m, p) => { + if (!m) { return m; } + return prop(p, m); + }, o, props); +}); + +export const set = curry((props, val, o) => { + if (!props.length) { return o; } + const nestedObj = get(init(props), o); + nestedObj[last(props)] = val; + return o; +}); diff --git a/src/fp.spec.js b/src/fp.spec.js index 1ac0249..0d18198 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -6,7 +6,7 @@ import {debug, identity, curry, passThrough, on2, init, tail, last, head, map, map_, reverse, reduce, compose, prop, zip, flip, toPairs, fromPairs, mapObject, mapValues, toObject, filter, clone, - filterObject, copyFunction} from './fp'; + filterObject, copyFunction, get, set} from './fp'; describe('fp', () => { describe('debug', () => { @@ -257,4 +257,34 @@ describe('fp', () => { expect(input.aProp).to.not.be.equal(res.aProp); }); }); + + describe('get', () => { + it('allows to access deep nested data by a list of keys', () => { + const x = { a: { b: { c: 1 } } }; + const actual = get(['a', 'b', 'c'], x); + expect(actual).to.equal(1); + }); + + it('returns undefined when a too deep path is given', () => { + const x = { a: 1 }; + const actual = get(['a', 'b', 'c'], x); + expect(actual).to.be.undefined; + }); + }); + + describe('set', () => { + it('allows to access set nested data by a list of keys and a new value', () => { + const keys = ['a', 'b', 'c']; + const x = { a: { b: { c: 1 } } }; + const nextX = set(keys, 2, x); + expect(get(keys, nextX)).to.equal(2); + }); + + it('returns an mutated copy of the original object', () => { + const keys = ['a', 'b', 'c']; + const x = { a: { b: { c: 1 } } }; + const nextX = set(keys, 2, x); // set to same value for easier testing + expect(nextX).to.equal(x); + }); + }); }); From 6f9078008e1897223acd922d945d266dc7c8e100 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 13 Mar 2017 21:23:49 +0100 Subject: [PATCH 017/136] Protect glob patterns in package.json from shell expansion --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 228ae42..6defbbb 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,8 @@ "scripts": { "docs:prepare": "gitbook install", "docs:watch": "npm run docs:prepare && gitbook serve", - "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js src/**/*.spec.js --require mocha.config", - "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js src/**/*.spec.js --require mocha.config", + "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", + "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "lint": "eslint src" }, "author": "Peter Crona (http://www.icecoldcode.com)", From 133ed122beee4c472ccb66b0c76925e467a578b0 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 07:14:04 +0100 Subject: [PATCH 018/136] Add more fp functions --- src/fp.js | 7 +++++++ src/fp.spec.js | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/fp.js b/src/fp.js index 017366c..4eb9704 100644 --- a/src/fp.js +++ b/src/fp.js @@ -166,3 +166,10 @@ export const set = curry((props, val, o) => { nestedObj[last(props)] = val; return o; }); + +export const flatten = (arrs) => reduce(concat, [], arrs); + +export const concat = curry((a, b) => a.concat(b)); + +export const uniq = (arr) => [...new Set(arr)]; + diff --git a/src/fp.spec.js b/src/fp.spec.js index 0d18198..65723e2 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -5,8 +5,8 @@ import {debug, identity, curry, passThrough, startsWith, join, on, isEqual, on2, init, tail, last, head, map, map_, reverse, reduce, compose, prop, zip, flip, toPairs, fromPairs, - mapObject, mapValues, toObject, filter, clone, - filterObject, copyFunction, get, set} from './fp'; + mapObject, mapValues, toObject, filter, clone, filterObject, + copyFunction, get, set, concat, flatten, uniq} from './fp'; describe('fp', () => { describe('debug', () => { @@ -287,4 +287,43 @@ describe('fp', () => { expect(nextX).to.equal(x); }); }); + + describe('concat', () => { + it('concatenates two lists', () => { + const a = [1] + const b = [2] + const expected = [1, 2]; + const actual = concat(a, b); + expect(actual).to.deep.equal(expected); + }); + }); + + describe('flatten', () => { + it('flattens a list of lists', () => { + const a = [1]; + const b = [2]; + const c = [3, 4]; + const expected = [1, 2, 3, 4]; + const actual = (flatten([a, b, c])); + expect(actual).to.deep.equal(expected); + }); + }); + + describe('uniq', () => { + it('returns unique items in a list of primitives', () => { + const list = [1, 2, 1, 1, 2, 3]; + const expected = [1, 2, 3]; + const actual = uniq(list); + expect(actual).to.deep.equal(expected); + }); + + it('returns unique items in a list of complex objects', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const list = [a, a, b, a, b, a]; + const expected = [a, b]; + const actual = uniq(list); + expect(actual).to.deep.equal(expected); + }); + }); }); From 0acbf129bac580189332e1345ef5dacf99ed34cb Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 07:14:18 +0100 Subject: [PATCH 019/136] Add preprocessing step to denormalizer --- src/plugins/denormalizer/index.js | 104 +++++++++++++++++++------ src/plugins/denormalizer/index.spec.js | 61 ++++++++++++++- 2 files changed, 138 insertions(+), 27 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index edb82cf..8b16b5f 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -1,4 +1,8 @@ -import { compose, curry, head, map, mapValues, prop, reduce, fromPairs, toPairs, toObject } from '../fp'; +import { + compose, curry, head, map, mapValues, + prop, reduce, fromPairs, toPairs, toObject, values, + uniq, flatten +} from '../../fp'; export const NAME = 'denormalizer'; @@ -14,6 +18,12 @@ const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityNa const getSchema = compose(prop('schema'), getPluginConf); +const getPluginConf_ = curry((config) => compose( + prop(NAME), + prop('plugins'), +)(config)); +const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); + const collectTargets = curry((schema, res, item) => { // TODO need to traverse the schema all the way down, in case they are nested const keys = Object.keys(schema); @@ -77,14 +87,12 @@ const requestEntities = curry((config, api, ids) => { return getSome(ids); }); -const resolve = curry((entityConfigs, schema, items) => { - const requestsToMake = compose(toPairs, reduce(collectTargets(schema), {}))(items); +const resolve = curry((accessors, getters, items) => { + const requestsToMake = compose(toPairs, reduce(collectTargets(getters), {}))(items); return Promise.all(map(([t, ids]) => { - const conf = getPluginConf(entityConfigs, t); - const api = getApi(entityConfigs, t); - return requestEntities(conf, api, ids).then((es) => [t, es]); + return requestEntities(accessors[t], ids).then((es) => [t, es]); }, requestsToMake)).then( - compose(resolveItems(schema, items), mapValues(toIdMap), fromPairs) + compose(resolveItems(getters, items), mapValues(toIdMap), fromPairs) ); }); @@ -93,21 +101,69 @@ const resolve = curry((entityConfigs, schema, items) => { // - We can prepare a data structure which makes handling of nested data easy // - We can validate if all necessary configuration is in place and fail fast if that's not the case -export const denormalizer = curry(( - { entityConfigs }, - { entity, apiFnName: name, apiFn: fn } -) => { - const schema = getSchema(entityConfigs, entity.name); - if (!schema) { - return fn; +const parseSchema = (schema) => { + return reduce((m, [field, val]) => { + if (Array.isArray(val) || typeof val === 'string') { + m[field] = val; + } else { + const nextSchema = parseSchema(val); + Object.keys(nextSchema).forEach((k) => { + m[[field, k].join('.')] = nextSchema[k]; + }); + } + return m; + }, {}, toPairs(schema)); + return {}; +}; + +export const extractAccessors = (configs) => { + return reduce((m, c) => { + const schema = getSchema_(c); + if (schema) { m[c.name] = parseSchema(schema); } + return m; + }, {}, configs); +}; + +const extractFetchers = (configs, types) => { + return compose(fromPairs, map((t) => { + const conf = getPluginConf(configs, t); + const api = getApi(configs, t); + if (!conf) { + throw new Error(`No denormalizer config found for type ${t}`); + } + + const fromApi = (p) => api[conf[p]]; + const getOne = fromApi('getOne'); + const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); + const getAll = fromApi('getAll') || (() => getSome(ids)); + + if (!getOne) { + throw new Error(`No 'getOne' accessor defined on type ${t}`); + } + return [t, { getOne, getSome, getAll }]; + }))(types); +} + +// Getters -> [Type] +const extractTypes = compose(uniq, flatten, flatten, map(values), values); + +export const denormalizer = () => ({ entityConfigs }) => { + const allAccessors = extractAccessors(values(entityConfigs)); + const allFetchers = extractFetchers(entityConfigs, extractTypes(allAccessors)); + + return ({ entity, apiFnName: name, apiFn: fn }) => { + const accessors = allAccessors[entity.name]; + if (!accessors) { + return fn; + } + return (...args) => { + return fn(...args).then((res) => { + const isArray = Array.isArray(res); + const items = isArray ? res : [res]; + + const resolved = resolve(allFetchers, accessors, items); + return isArray ? resolved : resolved.then(head); + }); + }; } - return (...args) => { - return fn(...args).then((res) => { - const isArray = Array.isArray(res); - const items = isArray ? res : [res]; - - const resolved = resolve(entityConfigs, schema, items); - return isArray ? resolved : resolved.then(head); - }); - }; -}); +}; diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index 3d2616d..c2de070 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -1,6 +1,6 @@ -import { build } from '../builder'; -import { denormalizer } from '.'; -import { curry, prop, head, last, toObject, values } from '../fp'; +import { build } from '../../builder'; +import { uniq, compose, map, flatten, curry, prop, head, last, toObject, values } from '../../fp'; +import { denormalizer, extractAccessors } from '.'; const toIdMap = toObject(prop('id')); @@ -136,3 +136,58 @@ describe('denormalizer', () => { }); }); +describe('denormalization-helpers', () => { + const createConfig = () => [ + { + name: 'message', + plugins: { + denormalizer: { + schema: { + author: 'user', + recipient: 'user', + visibleTo: ['user'], + nestedData: { + comments: ['comment'] + } + } + } + } + }, + { + name: 'review', + plugins: { + denormalizer: { + schema: { + author: 'user', + meta: { + data: { + comments: ['comment'] + } + } + } + } + } + } + ]; + + describe('extractAccessors', () => { + it('parses config and returns all paths to entities defined in schemas', () => { + const expected = { + message: { + author: 'user', + recipient: 'user', + visibleTo: ['user'], + 'nestedData.comments': ['comment'] + }, + review: { + author: 'user', + 'meta.data.comments': ['comment'] + } + }; + + const actual = extractAccessors(createConfig()); + expect(actual).to.deep.equal(expected); + }); + }); +}); + From fecd550b65ec6423a3441e1d8b774e3ba8338187 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 07:37:35 +0100 Subject: [PATCH 020/136] Make denormalizer work with preprocessing --- src/plugins/denormalizer/index.js | 67 +++++++++++-------------------- 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 8b16b5f..a516b9a 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -1,7 +1,7 @@ import { compose, curry, head, map, mapValues, prop, reduce, fromPairs, toPairs, toObject, values, - uniq, flatten + uniq, flatten, get, set } from '../../fp'; export const NAME = 'denormalizer'; @@ -24,58 +24,36 @@ const getPluginConf_ = curry((config) => compose( )(config)); const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); -const collectTargets = curry((schema, res, item) => { - // TODO need to traverse the schema all the way down, in case they are nested - const keys = Object.keys(schema); - return reduce((m, k) => { - const type = schema[k]; +const collectTargets = curry((accessors, res, item) => { + return compose(reduce((m, [path, type]) => { let list = m[type]; if (!list) { list = []; } - const val = item[k]; - // need to make sure we pass only unique values! - if (typeof val === 'object') { - // here we should traverse - if (Array.isArray(val)) { - list = list.concat(val); - } + const val = get(path.split('.'), item) // wasteful to do that all the time, try ealier + if (Array.isArray(val)) { + list = list.concat(val); } else { list.push(val); } - if (list.length) { - m[type] = list; - } + m[type] = list return m; - }, res, keys); + }, res), toPairs)(accessors); }); -const resolveItem = curry((schema, entities, item) => { - // reuse traversal function - const keys = Object.keys(schema); - return reduce((m, k) => { - const type = schema[k]; +const resolveItem = curry((accessors, entities, item) => { + return compose(reduce((m, [path, type]) => { + const splitPath = path.split('.'); + const val = get(path.split('.'), item) // wasteful to do that all the time, try ealier const getById = (id) => entities[type][id]; - const val = item[k]; - // typically a drill down would be needed here, we just return - // to make the original tests pass for now - if (typeof val === 'object' && !Array.isArray(val)) { - return m; - } const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); - return { ...m, [k]: resolvedVal }; - }, item, keys); + return set(splitPath, resolvedVal, { ...m }); + }, item), toPairs)(accessors); }); -const resolveItems = curry((schema, items, entities) => { - return map(resolveItem(schema, entities), items); +const resolveItems = curry((accessors, items, entities) => { + return map(resolveItem(accessors, entities), items); }); -const requestEntities = curry((config, api, ids) => { - const fromApi = (p) => api[config[p]]; - const getOne = fromApi('getOne'); - const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); - const getAll = fromApi('getAll') || (() => getSome(ids)); - const threshold = config.threshold || 0; - +const requestEntities = curry(({ getOne, getSome, getAll, threshold }, ids) => { const noOfItems = ids.length; if (noOfItems === 1) { @@ -87,12 +65,12 @@ const requestEntities = curry((config, api, ids) => { return getSome(ids); }); -const resolve = curry((accessors, getters, items) => { - const requestsToMake = compose(toPairs, reduce(collectTargets(getters), {}))(items); +const resolve = curry((fetchers, accessors, items) => { + const requestsToMake = compose(toPairs, reduce(collectTargets(accessors), {}))(items); return Promise.all(map(([t, ids]) => { - return requestEntities(accessors[t], ids).then((es) => [t, es]); + return requestEntities(fetchers[t], ids).then((es) => [t, es]); }, requestsToMake)).then( - compose(resolveItems(getters, items), mapValues(toIdMap), fromPairs) + compose(resolveItems(accessors, items), mapValues(toIdMap), fromPairs) ); }); @@ -136,11 +114,12 @@ const extractFetchers = (configs, types) => { const getOne = fromApi('getOne'); const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); const getAll = fromApi('getAll') || (() => getSome(ids)); + const threshold = fromApi('threshold') || 0; if (!getOne) { throw new Error(`No 'getOne' accessor defined on type ${t}`); } - return [t, { getOne, getSome, getAll }]; + return [t, { getOne, getSome, getAll, threshold }]; }))(types); } From 01694cb9be8ca79dfae0efee5e4e8392361b77b5 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 07:43:18 +0100 Subject: [PATCH 021/136] Add spec to verify nested data resolution --- src/plugins/denormalizer/index.spec.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index c2de070..a62eafb 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -116,6 +116,13 @@ describe('denormalizer', () => { .then(expectResolved('visibleTo', [users[m1.visibleTo[0]]])) .then(() => done()); }); + + it('resolves references for nested data', (done) => { + const api = build(config(), [denormalizer()]); + api.message.getMessage(m1.id) + .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) + .then(() => done()); + }) }); describe('with a fn, that returns a list of objects', () => { From b818f6f6b049072666f14b2ac173835a272d6e6e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:00:33 +0100 Subject: [PATCH 022/136] Make set immutable --- src/fp.js | 19 ++++++++++++++++--- src/fp.spec.js | 27 ++++++++++++++++++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/fp.js b/src/fp.js index 4eb9704..ef7f59f 100644 --- a/src/fp.js +++ b/src/fp.js @@ -160,11 +160,24 @@ export const get = curry((props, o) => { }, o, props); }); +/// a, b, c x o +// {}, {}, x +// +// [[['a'] , {}], [['a', 'b'], { c: x }]] +// const update (p, val i) +// + export const set = curry((props, val, o) => { if (!props.length) { return o; } - const nestedObj = get(init(props), o); - nestedObj[last(props)] = val; - return o; + const update = (items, obj) => { + if (!items.length) { return obj; } + const [k, nextVal] = head(items); + const next = items.length === 1 ? nextVal : { ...prop(k, obj), ...nextVal }; + return { ...obj, [k]: update(tail(items), next) } + }; + + const zipped = [...map((k) => [k, {}], init(props)), [last(props), val]]; + return update(zipped, o); }); export const flatten = (arrs) => reduce(concat, [], arrs); diff --git a/src/fp.spec.js b/src/fp.spec.js index 65723e2..a38ce8a 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -275,17 +275,34 @@ describe('fp', () => { describe('set', () => { it('allows to access set nested data by a list of keys and a new value', () => { const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } } }; + const x = { a: { b: { c: 1 } }, x: { y: '0' } }; const nextX = set(keys, 2, x); expect(get(keys, nextX)).to.equal(2); }); - it('returns an mutated copy of the original object', () => { + it('returns an immutable copy of the original object', () => { const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } } }; - const nextX = set(keys, 2, x); // set to same value for easier testing - expect(nextX).to.equal(x); + const x = { a: { b: { c: 1 } }, x: { y: '0' } }; + const nextX = set(keys, 2, x); + expect(nextX).not.to.equal(x); + }); + + it('treats all items along the key path as immutable', () => { + const keys = ['a', 'b', 'c']; + const x = { a: { b: { c: 1 } }, x: { y: '0' } }; + const nextX = set(keys, 2, x); + expect(nextX.a).not.to.equal(x.a); + expect(nextX.a.b).not.to.equal(x.a.b); }); + + it('does not update references to obj which are not along the path', () => { + const keys = ['a', 'b', 'c']; + const x = { a: { b: { c: 1 } }, x: { y: '0' } }; + const nextX = set(keys, 2, x); + expect(nextX.x).to.equal(x.x); + }); + + }); describe('concat', () => { From 1a229c0ebba85a9ea2418030c5c3a3a90b999ef3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:01:11 +0100 Subject: [PATCH 023/136] Indent and use immutability of set --- src/plugins/denormalizer/index.js | 2 +- src/plugins/denormalizer/index.spec.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index a516b9a..0389a08 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -45,7 +45,7 @@ const resolveItem = curry((accessors, entities, item) => { const val = get(path.split('.'), item) // wasteful to do that all the time, try ealier const getById = (id) => entities[type][id]; const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); - return set(splitPath, resolvedVal, { ...m }); + return set(splitPath, resolvedVal, m); }, item), toPairs)(accessors); }); diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index a62eafb..e05b36c 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -120,7 +120,7 @@ describe('denormalizer', () => { it('resolves references for nested data', (done) => { const api = build(config(), [denormalizer()]); api.message.getMessage(m1.id) - .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) + .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) .then(() => done()); }) }); From b141474435511e5dc57023996b1b7e4dacbedf2a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:02:32 +0100 Subject: [PATCH 024/136] Remove helper comments --- src/fp.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/fp.js b/src/fp.js index ef7f59f..d0fb973 100644 --- a/src/fp.js +++ b/src/fp.js @@ -160,13 +160,6 @@ export const get = curry((props, o) => { }, o, props); }); -/// a, b, c x o -// {}, {}, x -// -// [[['a'] , {}], [['a', 'b'], { c: x }]] -// const update (p, val i) -// - export const set = curry((props, val, o) => { if (!props.length) { return o; } const update = (items, obj) => { From ecd96248f9f2016e0d4ca4a42cd536ed5e6157ac Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:03:38 +0100 Subject: [PATCH 025/136] Remove comments --- src/plugins/denormalizer/index.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 0389a08..892b804 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -74,11 +74,6 @@ const resolve = curry((fetchers, accessors, items) => { ); }); -// TODO preprocess configuration. This has several benefits -// - We don't need traverse the config on the fly all the time -// - We can prepare a data structure which makes handling of nested data easy -// - We can validate if all necessary configuration is in place and fail fast if that's not the case - const parseSchema = (schema) => { return reduce((m, [field, val]) => { if (Array.isArray(val) || typeof val === 'string') { From 1ac2e3954e4bd45d21cc3ae4b13879a890328da4 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:31:31 +0100 Subject: [PATCH 026/136] Add fst and snd to fp --- src/fp.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fp.js b/src/fp.js index d0fb973..f58deed 100644 --- a/src/fp.js +++ b/src/fp.js @@ -179,3 +179,6 @@ export const concat = curry((a, b) => a.concat(b)); export const uniq = (arr) => [...new Set(arr)]; +export const fst = (arr) => arr[0]; +export const snd = (arr) => arr[1]; + From bbd7a4a7eb5383b17442889b7095861b2cb58579 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:32:11 +0100 Subject: [PATCH 027/136] Simplify - no need to transform paths everytime --- src/plugins/denormalizer/index.js | 22 +++++++++++----------- src/plugins/denormalizer/index.spec.js | 20 ++++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 892b804..eb75465 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -1,7 +1,7 @@ import { compose, curry, head, map, mapValues, prop, reduce, fromPairs, toPairs, toObject, values, - uniq, flatten, get, set + uniq, flatten, get, set, snd } from '../../fp'; export const NAME = 'denormalizer'; @@ -25,10 +25,10 @@ const getPluginConf_ = curry((config) => compose( const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); const collectTargets = curry((accessors, res, item) => { - return compose(reduce((m, [path, type]) => { + return reduce((m, [path, type]) => { let list = m[type]; if (!list) { list = []; } - const val = get(path.split('.'), item) // wasteful to do that all the time, try ealier + const val = get(path, item) if (Array.isArray(val)) { list = list.concat(val); } else { @@ -36,17 +36,16 @@ const collectTargets = curry((accessors, res, item) => { } m[type] = list return m; - }, res), toPairs)(accessors); + }, res, accessors); }); const resolveItem = curry((accessors, entities, item) => { - return compose(reduce((m, [path, type]) => { - const splitPath = path.split('.'); - const val = get(path.split('.'), item) // wasteful to do that all the time, try ealier + return reduce((m, [path, type]) => { + const val = get(path, item) // wasteful to do that all the time, try ealier const getById = (id) => entities[type][id]; const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); - return set(splitPath, resolvedVal, m); - }, item), toPairs)(accessors); + return set(path, resolvedVal, m); + }, item, accessors); }); const resolveItems = curry((accessors, items, entities) => { @@ -90,11 +89,12 @@ const parseSchema = (schema) => { }; export const extractAccessors = (configs) => { - return reduce((m, c) => { + const asMap = reduce((m, c) => { const schema = getSchema_(c); if (schema) { m[c.name] = parseSchema(schema); } return m; }, {}, configs); + return mapValues(compose(map(([ps, v]) => [ps.split('.'), v]), toPairs))(asMap); }; const extractFetchers = (configs, types) => { @@ -119,7 +119,7 @@ const extractFetchers = (configs, types) => { } // Getters -> [Type] -const extractTypes = compose(uniq, flatten, flatten, map(values), values); +const extractTypes = compose(uniq, flatten, map(snd), flatten, values); export const denormalizer = () => ({ entityConfigs }) => { const allAccessors = extractAccessors(values(entityConfigs)); diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index e05b36c..1fb60c6 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -180,16 +180,16 @@ describe('denormalization-helpers', () => { describe('extractAccessors', () => { it('parses config and returns all paths to entities defined in schemas', () => { const expected = { - message: { - author: 'user', - recipient: 'user', - visibleTo: ['user'], - 'nestedData.comments': ['comment'] - }, - review: { - author: 'user', - 'meta.data.comments': ['comment'] - } + message: [ + [['author'], 'user'], + [['recipient'], 'user'], + [['visibleTo'], ['user']], + [['nestedData', 'comments'], ['comment']] + ], + review: [ + [['author'], 'user'], + [['meta', 'data', 'comments'], ['comment']] + ] }; const actual = extractAccessors(createConfig()); From 4bd186b2ca650b0f36b30c7fd0be4791c44bf158 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:43:46 +0100 Subject: [PATCH 028/136] Add some type info --- src/plugins/denormalizer/index.js | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index eb75465..ff16a47 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -4,6 +4,21 @@ import { uniq, flatten, get, set, snd } from '../../fp'; +/* TYPES + * + * Path = [String] + * + * Accessor = [ (Path, Type | [Type]) ] + * + * Fetcher = { + * getOne: id -> Promise Entity + * getSome: [id] -> Promise [Entity] + * getAll: Promise [Entity] + * threshold: Int + * } + * + */ + export const NAME = 'denormalizer'; const toIdMap = toObject(prop('id')); @@ -88,6 +103,8 @@ const parseSchema = (schema) => { return {}; }; + +// EntityConfigs -> Map String Accessors export const extractAccessors = (configs) => { const asMap = reduce((m, c) => { const schema = getSchema_(c); @@ -97,6 +114,7 @@ export const extractAccessors = (configs) => { return mapValues(compose(map(([ps, v]) => [ps.split('.'), v]), toPairs))(asMap); }; +// EntityConfigs -> [Type] -> Map String Fetcher const extractFetchers = (configs, types) => { return compose(fromPairs, map((t) => { const conf = getPluginConf(configs, t); @@ -118,7 +136,7 @@ const extractFetchers = (configs, types) => { }))(types); } -// Getters -> [Type] +// Map String Accessors -> [Type] const extractTypes = compose(uniq, flatten, map(snd), flatten, values); export const denormalizer = () => ({ entityConfigs }) => { From 703daa969cf543db5b374357c4cfb986e256df22 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:53:28 +0100 Subject: [PATCH 029/136] Simplify yet again --- src/plugins/denormalizer/index.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index ff16a47..a5816f3 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -1,5 +1,5 @@ import { - compose, curry, head, map, mapValues, + compose, curry, head, map, mapObject, mapValues, prop, reduce, fromPairs, toPairs, toObject, values, uniq, flatten, get, set, snd } from '../../fp'; @@ -8,7 +8,7 @@ import { * * Path = [String] * - * Accessor = [ (Path, Type | [Type]) ] + * Accessors = [ (Path, Type | [Type]) ] * * Fetcher = { * getOne: id -> Promise Entity @@ -23,20 +23,12 @@ export const NAME = 'denormalizer'; const toIdMap = toObject(prop('id')); -const getPluginConf = curry((configs, entityName) => compose( - prop(NAME), - prop('plugins'), - prop(entityName) -)(configs)); - const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); -const getSchema = compose(prop('schema'), getPluginConf); +const getPluginConf = curry((cs, entityName) => getPluginConf_(cs[entityName])); + +const getPluginConf_ = curry((config) => compose(prop(NAME), prop('plugins'))(config)); -const getPluginConf_ = curry((config) => compose( - prop(NAME), - prop('plugins'), -)(config)); const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); const collectTargets = curry((accessors, res, item) => { @@ -80,8 +72,8 @@ const requestEntities = curry(({ getOne, getSome, getAll, threshold }, ids) => { }); const resolve = curry((fetchers, accessors, items) => { - const requestsToMake = compose(toPairs, reduce(collectTargets(accessors), {}))(items); - return Promise.all(map(([t, ids]) => { + const requestsToMake = compose(reduce(collectTargets(accessors), {}))(items); + return Promise.all(mapObject(([t, ids]) => { return requestEntities(fetchers[t], ids).then((es) => [t, es]); }, requestsToMake)).then( compose(resolveItems(accessors, items), mapValues(toIdMap), fromPairs) @@ -114,7 +106,7 @@ export const extractAccessors = (configs) => { return mapValues(compose(map(([ps, v]) => [ps.split('.'), v]), toPairs))(asMap); }; -// EntityConfigs -> [Type] -> Map String Fetcher +// EntityConfigs -> [Type] -> Map Type Fetcher const extractFetchers = (configs, types) => { return compose(fromPairs, map((t) => { const conf = getPluginConf(configs, t); @@ -136,7 +128,7 @@ const extractFetchers = (configs, types) => { }))(types); } -// Map String Accessors -> [Type] +// Map Type Accessors -> [Type] const extractTypes = compose(uniq, flatten, map(snd), flatten, values); export const denormalizer = () => ({ entityConfigs }) => { From 14e73898acd0c55c7b8c4791df3d9a4953a8bd08 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 09:56:52 +0100 Subject: [PATCH 030/136] Remove old comment --- src/plugins/denormalizer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index a5816f3..b89c6ea 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -48,7 +48,7 @@ const collectTargets = curry((accessors, res, item) => { const resolveItem = curry((accessors, entities, item) => { return reduce((m, [path, type]) => { - const val = get(path, item) // wasteful to do that all the time, try ealier + const val = get(path, item); const getById = (id) => entities[type][id]; const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); return set(path, resolvedVal, m); From 0f4fdd829183d172fa25cae19df66c1748a03e09 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 19:17:53 +0100 Subject: [PATCH 031/136] Move core plugin definition to decorator --- src/builder.js | 12 ++-------- src/decorator/index.js | 53 ++++++++++++++---------------------------- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/src/builder.js b/src/builder.js index 681aa95..25d2beb 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,8 +1,6 @@ import {mapObject, mapValues, values, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; -import {createEntityStore} from './entity-store'; -import {createQueryCache} from './query-cache'; -import {decorate2} from './decorator'; +import {decoratorPlugin} from './decorator'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -98,12 +96,6 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -const corePlugin = ({ config, entityConfigs }) => { - const entityStore = compose(createEntityStore, values)(entityConfigs); - const queryCache = createQueryCache(entityStore); - return decorate2(entityStore, queryCache, { config, entityConfigs }); -}; - const applyPlugin = curry((config, entityConfigs, plugin) => { const pluginDecorator = plugin({ config, entityConfigs }); return mapApiFunctions(pluginDecorator, entityConfigs); @@ -113,5 +105,5 @@ const applyPlugin = curry((config, entityConfigs, plugin) => { export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const createApi = compose(toApi, reduce(applyPlugin(config), getEntityConfigs(c))); - return createApi([corePlugin, ...ps]); + return createApi([decoratorPlugin, ...ps]); }; diff --git a/src/decorator/index.js b/src/decorator/index.js index beeeb37..a0db6b5 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -1,45 +1,26 @@ -import {curry, mapValues} from '../fp'; +import {compose, values} from '../fp'; +import {createEntityStore} from '../entity-store'; +import {createQueryCache} from '../query-cache'; import {decorateCreate} from './create'; import {decorateRead} from './read'; import {decorateUpdate} from './update'; import {decorateDelete} from './delete'; import {decorateNoOperation} from './no-operation'; -export const decorateApi = curry((config, entityStore, queryCache, entity, apiFn) => { - const handler = { - CREATE: decorateCreate, - READ: decorateRead, - UPDATE: decorateUpdate, - DELETE: decorateDelete, - NO_OPERATION: decorateNoOperation - }[apiFn.operation]; - return handler(config, entityStore, queryCache, entity, apiFn); -}); +const HANDLERS = { + CREATE: decorateCreate, + READ: decorateRead, + UPDATE: decorateUpdate, + DELETE: decorateDelete, + NO_OPERATION: decorateNoOperation +} -export const decorate = curry((config, entityStore, queryCache, entity) => { - const decoratedApi = mapValues( - decorateApi(config, entityStore, queryCache, entity), - entity.api - ); - return { - ...entity, - api: decoratedApi +export const decoratorPlugin = ({ config, entityConfigs }) => { + const entityStore = compose(createEntityStore, values)(entityConfigs); + const queryCache = createQueryCache(entityStore); + return ({ entity, apiFn }) => { + const handler = HANDLERS[apiFn.operation]; + return handler(config, entityStore, queryCache, entity, apiFn); }; -}); - -export const decorate2 = curry(( - entityStore, - queryCache, - { config, entityConfigs }, - { entity, apiFnName, apiFn } -) => { - const handler = { - CREATE: decorateCreate, - READ: decorateRead, - UPDATE: decorateUpdate, - DELETE: decorateDelete, - NO_OPERATION: decorateNoOperation - }[apiFn.operation]; - return handler(config, entityStore, queryCache, entity, apiFn); -}); +} From 5a595bf23c5fd8fcd3f91f4d362eac3b5d284a87 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 19:20:24 +0100 Subject: [PATCH 032/136] Set old decorator test pending @petercrona - which the new decorator interface, which is implemented as a plugin, this test cannot be written in this way anymore, because the internals of entityStore and queryCache are hidden. How important is the behavior here? I suppose the goal here is to test if cross entity functionality is correct (invalidation). Ideally we would not need to touch the internals of queryCache to test this here anyway. Is this functionality tested elsewhere so that we can just remove this test here, or should we try to re-write in a different way? --- src/decorator/index.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/decorator/index.spec.js b/src/decorator/index.spec.js index 09bad32..e19693f 100644 --- a/src/decorator/index.spec.js +++ b/src/decorator/index.spec.js @@ -31,7 +31,7 @@ const config = [ describe('Decorate', () => { - it('decorated function invalidates if NO_OPERATION is configured', (done) => { + xit('decorated function invalidates if NO_OPERATION is configured', (done) => { const aFn = createApiFunction(() => Promise.resolve('hej')); const xOrg = [{id: 1, name: 'Kalle'}]; const es = createEntityStore(config); From e39abd3558abd15c51b9add4fd659193f29a8579 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 19:52:33 +0100 Subject: [PATCH 033/136] Add another set test --- src/fp.spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fp.spec.js b/src/fp.spec.js index a38ce8a..40df5f0 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -302,6 +302,12 @@ describe('fp', () => { expect(nextX.x).to.equal(x.x); }); + it('returns the original object when the update path is empty', () => { + const x = {}; + const nextX = set([], 1, x); + expect(nextX).to.equal(x); + }) + }); From 903bfcc45ffaed446a5d912d2d5b99f26160b404 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:19:23 +0100 Subject: [PATCH 034/136] Make linter happy --- src/builder.js | 2 +- src/builder.spec.js | 11 ++++++----- src/decorator/index.js | 4 ++-- src/fp.js | 6 +++--- src/fp.spec.js | 8 +++----- src/plugins/denormalizer/index.js | 19 +++++++++---------- src/plugins/denormalizer/index.spec.js | 4 ++-- 7 files changed, 26 insertions(+), 28 deletions(-) diff --git a/src/builder.js b/src/builder.js index 25d2beb..34046c0 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,4 +1,4 @@ -import {mapObject, mapValues, values, compose, toObject, reduce, toPairs, +import {mapObject, mapValues, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {decoratorPlugin} from './decorator'; diff --git a/src/builder.spec.js b/src/builder.spec.js index 85a45aa..558d045 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -62,7 +62,7 @@ describe('builder', () => { myConfig.user.api.getUsers.idFrom = 'ARGS'; const api = build(myConfig); const start = Date.now(); - const checkTimeConstraint = (xs) => { + const checkTimeConstraint = () => { expect(Date.now() - start < 1000).to.be.true; done(); }; @@ -76,7 +76,9 @@ describe('builder', () => { it('Works with non default id set', (done) => { const myConfig = config(); myConfig.__config = {idField: 'mySecretId'}; - myConfig.user.api.getUsers = sinon.spy(() => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}])); + myConfig.user.api.getUsers = sinon.spy( + () => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}]) + ); myConfig.user.api.getUsers.operation = 'READ'; const api = build(myConfig); const expectOnlyOneApiCall = (xs) => { @@ -123,8 +125,7 @@ describe('builder', () => { .then(() => api.user.getUsers()) .then(delay) .then(() => api.user.getUsers()) - .then(expectOnlyOneApiCall) - .catch(e => console.log(e)); + .then(expectOnlyOneApiCall); }); it('takes plugins as second argument', (done) => { @@ -133,7 +134,7 @@ describe('builder', () => { const plugin = (pConfig) => { const pName = pConfig.name; pluginTracker[pName] = {}; - return curry(({ config: c, entityConfigs }, { entity, apiFnName, apiFn }) => { + return curry(({ config: c, entityConfigs }, { apiFnName, apiFn }) => { pluginTracker[pName][apiFnName] = true; return apiFn; }); diff --git a/src/decorator/index.js b/src/decorator/index.js index a0db6b5..43e06a6 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -13,7 +13,7 @@ const HANDLERS = { UPDATE: decorateUpdate, DELETE: decorateDelete, NO_OPERATION: decorateNoOperation -} +}; export const decoratorPlugin = ({ config, entityConfigs }) => { const entityStore = compose(createEntityStore, values)(entityConfigs); @@ -22,5 +22,5 @@ export const decoratorPlugin = ({ config, entityConfigs }) => { const handler = HANDLERS[apiFn.operation]; return handler(config, entityStore, queryCache, entity, apiFn); }; -} +}; diff --git a/src/fp.js b/src/fp.js index f58deed..9d0cc3b 100644 --- a/src/fp.js +++ b/src/fp.js @@ -166,17 +166,17 @@ export const set = curry((props, val, o) => { if (!items.length) { return obj; } const [k, nextVal] = head(items); const next = items.length === 1 ? nextVal : { ...prop(k, obj), ...nextVal }; - return { ...obj, [k]: update(tail(items), next) } + return { ...obj, [k]: update(tail(items), next) }; }; const zipped = [...map((k) => [k, {}], init(props)), [last(props), val]]; return update(zipped, o); }); -export const flatten = (arrs) => reduce(concat, [], arrs); - export const concat = curry((a, b) => a.concat(b)); +export const flatten = (arrs) => reduce(concat, [], arrs); + export const uniq = (arr) => [...new Set(arr)]; export const fst = (arr) => arr[0]; diff --git a/src/fp.spec.js b/src/fp.spec.js index 40df5f0..eccf839 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -306,15 +306,13 @@ describe('fp', () => { const x = {}; const nextX = set([], 1, x); expect(nextX).to.equal(x); - }) - - + }); }); describe('concat', () => { it('concatenates two lists', () => { - const a = [1] - const b = [2] + const a = [1]; + const b = [2]; const expected = [1, 2]; const actual = concat(a, b); expect(actual).to.deep.equal(expected); diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index b89c6ea..dd608bb 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -25,23 +25,23 @@ const toIdMap = toObject(prop('id')); const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); -const getPluginConf = curry((cs, entityName) => getPluginConf_(cs[entityName])); - const getPluginConf_ = curry((config) => compose(prop(NAME), prop('plugins'))(config)); const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); +const getPluginConf = curry((cs, entityName) => getPluginConf_(cs[entityName])); + const collectTargets = curry((accessors, res, item) => { return reduce((m, [path, type]) => { let list = m[type]; if (!list) { list = []; } - const val = get(path, item) + const val = get(path, item); if (Array.isArray(val)) { list = list.concat(val); } else { list.push(val); } - m[type] = list + m[type] = list; return m; }, res, accessors); }); @@ -65,7 +65,7 @@ const requestEntities = curry(({ getOne, getSome, getAll, threshold }, ids) => { if (noOfItems === 1) { return getOne(ids[0]).then((e) => [e]); } - if (noOfItems > threshold) { + if (noOfItems > threshold && getAll) { return getAll(); } return getSome(ids); @@ -92,7 +92,6 @@ const parseSchema = (schema) => { } return m; }, {}, toPairs(schema)); - return {}; }; @@ -118,7 +117,7 @@ const extractFetchers = (configs, types) => { const fromApi = (p) => api[conf[p]]; const getOne = fromApi('getOne'); const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); - const getAll = fromApi('getAll') || (() => getSome(ids)); + const getAll = fromApi('getAll'); const threshold = fromApi('threshold') || 0; if (!getOne) { @@ -126,7 +125,7 @@ const extractFetchers = (configs, types) => { } return [t, { getOne, getSome, getAll, threshold }]; }))(types); -} +}; // Map Type Accessors -> [Type] const extractTypes = compose(uniq, flatten, map(snd), flatten, values); @@ -135,7 +134,7 @@ export const denormalizer = () => ({ entityConfigs }) => { const allAccessors = extractAccessors(values(entityConfigs)); const allFetchers = extractFetchers(entityConfigs, extractTypes(allAccessors)); - return ({ entity, apiFnName: name, apiFn: fn }) => { + return ({ entity, apiFn: fn }) => { const accessors = allAccessors[entity.name]; if (!accessors) { return fn; @@ -149,5 +148,5 @@ export const denormalizer = () => ({ entityConfigs }) => { return isArray ? resolved : resolved.then(head); }); }; - } + }; }; diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index 1fb60c6..b45eaee 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -1,5 +1,5 @@ import { build } from '../../builder'; -import { uniq, compose, map, flatten, curry, prop, head, last, toObject, values } from '../../fp'; +import { curry, prop, head, last, toObject, values } from '../../fp'; import { denormalizer, extractAccessors } from '.'; const toIdMap = toObject(prop('id')); @@ -122,7 +122,7 @@ describe('denormalizer', () => { api.message.getMessage(m1.id) .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) .then(() => done()); - }) + }); }); describe('with a fn, that returns a list of objects', () => { From f238bd90dd6abaa0a9e8529dc8d3d520d1d6a090 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:13:20 +0100 Subject: [PATCH 035/136] Simplify name of decorator plugin --- src/builder.js | 4 ++-- src/decorator/index.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/builder.js b/src/builder.js index 34046c0..1b777a0 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,6 +1,6 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; -import {decoratorPlugin} from './decorator'; +import {decorator} from './decorator'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -105,5 +105,5 @@ const applyPlugin = curry((config, entityConfigs, plugin) => { export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const createApi = compose(toApi, reduce(applyPlugin(config), getEntityConfigs(c))); - return createApi([decoratorPlugin, ...ps]); + return createApi([decorator, ...ps]); }; diff --git a/src/decorator/index.js b/src/decorator/index.js index 43e06a6..a8d5826 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -15,7 +15,7 @@ const HANDLERS = { NO_OPERATION: decorateNoOperation }; -export const decoratorPlugin = ({ config, entityConfigs }) => { +export const decorator = ({ config, entityConfigs }) => { const entityStore = compose(createEntityStore, values)(entityConfigs); const queryCache = createQueryCache(entityStore); return ({ entity, apiFn }) => { From c87fc571666ac6db7923c6b1727205b9ec7525cf Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:58:55 +0100 Subject: [PATCH 036/136] Default threshold to Infinity --- src/plugins/denormalizer/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index dd608bb..0c18210 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -118,7 +118,7 @@ const extractFetchers = (configs, types) => { const getOne = fromApi('getOne'); const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); const getAll = fromApi('getAll'); - const threshold = fromApi('threshold') || 0; + const threshold = fromApi('threshold') || Infinity; if (!getOne) { throw new Error(`No 'getOne' accessor defined on type ${t}`); From 4c3e5579c875d3b6a968fb4c0afed2b0109bf370 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:07:35 +0100 Subject: [PATCH 037/136] Add error handling specs --- src/plugins/denormalizer/index.js | 6 ++-- src/plugins/denormalizer/index.spec.js | 45 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 0c18210..20a2c47 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -21,13 +21,15 @@ import { export const NAME = 'denormalizer'; +const def = curry((a, b) => b || a); + const toIdMap = toObject(prop('id')); const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); -const getPluginConf_ = curry((config) => compose(prop(NAME), prop('plugins'))(config)); +const getPluginConf_ = curry((config) => compose(prop(NAME), def({}), prop('plugins'))(config)); -const getSchema_ = (config) => compose(prop('schema'), getPluginConf_)(config); +const getSchema_ = (config) => compose(prop('schema'), def({}), getPluginConf_)(config); const getPluginConf = curry((cs, entityName) => getPluginConf_(cs[entityName])); diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index b45eaee..f8d3ecd 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -123,6 +123,51 @@ describe('denormalizer', () => { .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) .then(() => done()); }); + + it('fails immediately when no config for an entity present in a schema is defined', () => { + const conf = { + user: { + api: {} + }, + message: { + api: {}, + plugins: { + denormalizer: { + schema: { + author: 'user' + } + } + } + } + }; + + const start = () => build(conf, [denormalizer()]); + expect(start).to.throw(/no.*config.*user/i); + }); + + it('fails immediately when config for an entity present in a schema is incomplete', () => { + const conf = { + user: { + api: {}, + plugins: { + denormalizer: {} + } + }, + message: { + api: {}, + plugins: { + denormalizer: { + schema: { + author: 'user' + } + } + } + } + }; + + const start = () => build(conf, [denormalizer()]); + expect(start).to.throw(/no.*getOne.*user/i); + }); }); describe('with a fn, that returns a list of objects', () => { From f169fb36ce94cccfaa9703fb3ab3db78d58147d1 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:09:46 +0100 Subject: [PATCH 038/136] Allow to define threshold globally --- src/plugins/denormalizer/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 20a2c47..1798e6b 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -107,8 +107,8 @@ export const extractAccessors = (configs) => { return mapValues(compose(map(([ps, v]) => [ps.split('.'), v]), toPairs))(asMap); }; -// EntityConfigs -> [Type] -> Map Type Fetcher -const extractFetchers = (configs, types) => { +// PluginConfig -> EntityConfigs -> [Type] -> Map Type Fetcher +const extractFetchers = (pluginConfig, configs, types) => { return compose(fromPairs, map((t) => { const conf = getPluginConf(configs, t); const api = getApi(configs, t); @@ -120,7 +120,7 @@ const extractFetchers = (configs, types) => { const getOne = fromApi('getOne'); const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); const getAll = fromApi('getAll'); - const threshold = fromApi('threshold') || Infinity; + const threshold = fromApi('threshold') || pluginConfig.threshold || Infinity; if (!getOne) { throw new Error(`No 'getOne' accessor defined on type ${t}`); @@ -132,9 +132,9 @@ const extractFetchers = (configs, types) => { // Map Type Accessors -> [Type] const extractTypes = compose(uniq, flatten, map(snd), flatten, values); -export const denormalizer = () => ({ entityConfigs }) => { +export const denormalizer = (pluginConfig = {}) => ({ entityConfigs }) => { const allAccessors = extractAccessors(values(entityConfigs)); - const allFetchers = extractFetchers(entityConfigs, extractTypes(allAccessors)); + const allFetchers = extractFetchers(pluginConfig, entityConfigs, extractTypes(allAccessors)); return ({ entity, apiFn: fn }) => { const accessors = allAccessors[entity.name]; From 97b070ce7d9d304b978949936deed76d6a52242a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:03:58 +0100 Subject: [PATCH 039/136] Add sinon-chai --- mocha.config.js | 5 ++++- package.json | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mocha.config.js b/mocha.config.js index f7c9465..21fc258 100644 --- a/mocha.config.js +++ b/mocha.config.js @@ -1,4 +1,7 @@ -import { expect } from 'chai'; +import chai, { expect } from 'chai'; +import sinonChai from 'sinon-chai'; + +chai.use(sinonChai); global.fdescribe = (...args) => describe.only(...args); global.fit = (...args) => it.only(...args); diff --git a/package.json b/package.json index 6defbbb..9c7a2f3 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "gitbook-cli": "^2.3.0", "mocha": "^2.5.3", "nyc": "^10.1.2", - "sinon": "^1.17.7" + "sinon": "^1.17.7", + "sinon-chai": "^2.8.0" }, "scripts": { "docs:prepare": "gitbook install", From b0fa694f95e230e701cc68df6cdf9b1ae1587a1b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:22:31 +0100 Subject: [PATCH 040/136] Add getOne spec --- src/plugins/denormalizer/index.spec.js | 45 ++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index f8d3ecd..0958623 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -1,3 +1,7 @@ +/* eslint-disable no-unused-expressions */ + +import sinon from 'sinon'; + import { build } from '../../builder'; import { curry, prop, head, last, toObject, values } from '../../fp'; import { denormalizer, extractAccessors } from '.'; @@ -168,6 +172,47 @@ describe('denormalizer', () => { const start = () => build(conf, [denormalizer()]); expect(start).to.throw(/no.*getOne.*user/i); }); + + it('calls getOne when there is only one entity to resolve', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve()); + const authorId = 'x'; + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + denormalizer: {} + } + } + }, + message: { + api: { + get: () => Promise.resolve({ author: authorId }) + }, + plugins: { + denormalizer: { + schema: { + author: 'user' + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getOneSpy).to.have.been.calledOnce; + expect(getOneSpy).to.have.been.calledWith(authorId); + expect(getSomeSpy).not.to.have.been.called; + }); + }); }); describe('with a fn, that returns a list of objects', () => { From bfa43a029e27c88bf88c65f12b0da4ac3fdb0bb7 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:27:55 +0100 Subject: [PATCH 041/136] Add threshold test and fix it --- src/plugins/denormalizer/index.js | 2 +- src/plugins/denormalizer/index.spec.js | 45 ++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 1798e6b..4b00144 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -120,7 +120,7 @@ const extractFetchers = (pluginConfig, configs, types) => { const getOne = fromApi('getOne'); const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); const getAll = fromApi('getAll'); - const threshold = fromApi('threshold') || pluginConfig.threshold || Infinity; + const threshold = conf.threshold || pluginConfig.threshold || Infinity; if (!getOne) { throw new Error(`No 'getOne' accessor defined on type ${t}`); diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index 0958623..7179e66 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -187,8 +187,7 @@ describe('denormalizer', () => { plugins: { denormalizer: { getOne: 'getOne', - getSome: 'getSome', - denormalizer: {} + getSome: 'getSome' } } }, @@ -213,6 +212,48 @@ describe('denormalizer', () => { expect(getSomeSpy).not.to.have.been.called; }); }); + + it('calls getAll when items requested is above threshold', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve([])); + const getAllSpy = sinon.spy(() => Promise.resolve([])); + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy, + getAll: getAllSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + getAll: 'getAll', + threshold: 2 + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getSomeSpy).not.to.have.been.called; + expect(getAllSpy).to.have.been.calledOnce; + }); + }); }); describe('with a fn, that returns a list of objects', () => { From 52c3ed484a4eaa0d9c9086cadf1accae7d519b4f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:36:38 +0100 Subject: [PATCH 042/136] Add specs to document when which apiFn is called for resolution --- src/plugins/denormalizer/index.spec.js | 207 ++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 3 deletions(-) diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index 7179e66..b653f3f 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -193,12 +193,12 @@ describe('denormalizer', () => { }, message: { api: { - get: () => Promise.resolve({ author: authorId }) + get: () => Promise.resolve({ authors: [authorId] }) }, plugins: { denormalizer: { schema: { - author: 'user' + authors: ['user'] } } } @@ -213,7 +213,7 @@ describe('denormalizer', () => { }); }); - it('calls getAll when items requested is above threshold', () => { + it('calls getAll when multiple items requested is above threshold', () => { const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); const getSomeSpy = sinon.spy(() => Promise.resolve([])); const getAllSpy = sinon.spy(() => Promise.resolve([])); @@ -254,6 +254,207 @@ describe('denormalizer', () => { expect(getAllSpy).to.have.been.calledOnce; }); }); + + it('calls getSome when multiple items requested are below threshold', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve([])); + const getAllSpy = sinon.spy(() => Promise.resolve([])); + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy, + getAll: getAllSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + getAll: 'getAll', + threshold: 3 + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getSomeSpy).to.have.been.called; + expect(getSomeSpy).to.have.been.calledWith(['a', 'b']); + expect(getAllSpy).not.to.have.been.calledOnce; + }); + }); + + it('calls getSome when multiple items requested are at threshold', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve([])); + const getAllSpy = sinon.spy(() => Promise.resolve([])); + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy, + getAll: getAllSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + getAll: 'getAll', + threshold: 2 + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getSomeSpy).to.have.been.called; + expect(getAllSpy).not.to.have.been.calledOnce; + }); + }); + + it('calls getSome when items requested are above threshold, but no getAll present', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve([])); + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + threshold: 1 + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getSomeSpy).to.have.been.called; + expect(getSomeSpy).to.have.been.calledWith(['a', 'b']); + }); + }); + + it('calls getOne several times when there is nothing else defined', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + + const conf = { + user: { + api: { + getOne: getOneSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne' + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer()]); + return api.message.get().then(() => { + expect(getOneSpy).to.have.been.calledTwice; + expect(getOneSpy).to.have.been.calledWith('a'); + expect(getOneSpy).to.have.been.calledWith('b'); + }); + }); + + it('allows to define a global threshold', () => { + const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); + const getSomeSpy = sinon.spy(() => Promise.resolve([])); + const getAllSpy = sinon.spy(() => Promise.resolve([])); + + const conf = { + user: { + api: { + getOne: getOneSpy, + getSome: getSomeSpy, + getAll: getAllSpy + }, + plugins: { + denormalizer: { + getOne: 'getOne', + getSome: 'getSome', + getAll: 'getAll' + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + } + }; + + const api = build(conf, [denormalizer({ threshold: 2 })]); + return api.message.get().then(() => { + expect(getSomeSpy).not.to.have.been.called; + expect(getAllSpy).to.have.been.calledOnce; + }); + }); }); describe('with a fn, that returns a list of objects', () => { From 9e25ae3fdc3cfeb96b06748d95c5ab59a77c1ff3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 22:40:59 +0100 Subject: [PATCH 043/136] Add a sanity spec --- src/plugins/denormalizer/index.spec.js | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index b653f3f..de0cbfb 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -455,6 +455,41 @@ describe('denormalizer', () => { expect(getAllSpy).to.have.been.calledOnce; }); }); + + it('does not fall when entities are unused and have no conf defined', () => { + const conf = { + user: { + api: { + getOne: () => Promise.resolve() + }, + plugins: { + denormalizer: { + getOne: 'getOne' + } + } + }, + message: { + api: { + get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) + }, + plugins: { + denormalizer: { + schema: { + authors: ['user'] + } + } + } + }, + comment: { + api: { + get: () => Promise.resolve() + } + } + }; + + const start = () => build(conf, [denormalizer()]); + expect(start).not.to.throw; + }); }); describe('with a fn, that returns a list of objects', () => { From 65c529cf2fa3dcebe732d26300751c9c2a0b50f7 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 20:31:54 +0100 Subject: [PATCH 044/136] feat(plugin-api) Use name property of apiFns directly --- src/builder.js | 17 +++++++++++++++-- src/builder.spec.js | 6 +++--- src/decorator/index.js | 6 +++--- src/plugins/denormalizer/index.js | 2 +- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/builder.js b/src/builder.js index 1b777a0..1da2539 100644 --- a/src/builder.js +++ b/src/builder.js @@ -17,6 +17,13 @@ const KNOWN_STATICS = { arity: true }; +const setFnName = curry((name, fn) => { + Object.defineProperty(fn, 'name', { writable: true }); + fn.name = name; + Object.defineProperty(fn, 'name', { writable: false }); + return fn; +}); + const hoistMetaData = (a, b) => { const keys = Object.getOwnPropertyNames(a); for (let i = keys.length - 1; i >= 0; i--) { @@ -25,6 +32,7 @@ const hoistMetaData = (a, b) => { b[k] = a[k]; } } + setFnName(a.name, b); return b; }; @@ -33,10 +41,15 @@ export const mapApiFunctions = (fn, entityConfigs) => { return { ...entity, api: reduce( + // As apiFn name we use key of the api field and not the name of the + // fn directly. This is controversial. Decision was made because + // the original function name might be polluted at this point, e.g. + // containing a "bound" prefix. (apiM, [apiFnName, apiFn]) => { const getFn = compose(prop(apiFnName), prop('api')); - const nextFn = fn({ entity, apiFnName, apiFn }); - apiM[apiFnName] = hoistMetaData(getFn(entity), nextFn); + const nextFn = hoistMetaData(getFn(entity), fn({ entity, fn: apiFn })); + setFnName(apiFnName, nextFn); + apiM[apiFnName] = nextFn; return apiM; }, {}, diff --git a/src/builder.spec.js b/src/builder.spec.js index 558d045..3c39a21 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -134,9 +134,9 @@ describe('builder', () => { const plugin = (pConfig) => { const pName = pConfig.name; pluginTracker[pName] = {}; - return curry(({ config: c, entityConfigs }, { apiFnName, apiFn }) => { - pluginTracker[pName][apiFnName] = true; - return apiFn; + return curry(({ config: c, entityConfigs }, { fn }) => { + pluginTracker[pName][fn.name] = true; + return fn; }); }; const pluginName = 'X'; diff --git a/src/decorator/index.js b/src/decorator/index.js index a8d5826..f061c63 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -18,9 +18,9 @@ const HANDLERS = { export const decorator = ({ config, entityConfigs }) => { const entityStore = compose(createEntityStore, values)(entityConfigs); const queryCache = createQueryCache(entityStore); - return ({ entity, apiFn }) => { - const handler = HANDLERS[apiFn.operation]; - return handler(config, entityStore, queryCache, entity, apiFn); + return ({ entity, fn }) => { + const handler = HANDLERS[fn.operation]; + return handler(config, entityStore, queryCache, entity, fn); }; }; diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 4b00144..6e0891a 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -136,7 +136,7 @@ export const denormalizer = (pluginConfig = {}) => ({ entityConfigs }) => { const allAccessors = extractAccessors(values(entityConfigs)); const allFetchers = extractFetchers(pluginConfig, entityConfigs, extractTypes(allAccessors)); - return ({ entity, apiFn: fn }) => { + return ({ entity, fn }) => { const accessors = allAccessors[entity.name]; if (!accessors) { return fn; From 9c0ee86d8ac393faa2370d1da8a58ad66f2e7e5a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:26:54 +0100 Subject: [PATCH 045/136] Add specs for dedup --- src/dedup/index.spec.js | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/dedup/index.spec.js diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js new file mode 100644 index 0000000..afe0208 --- /dev/null +++ b/src/dedup/index.spec.js @@ -0,0 +1,81 @@ +import sinon from 'sinon'; +import { dedup } from '.'; + +const delay = (cb, t = 5) => new Promise((res, rej) => { + setTimeout(() => cb().then(res, rej), t); +}); + +describe('dedup', () => { + describe('for operations other than READ', () => { + it('just returns the original apiFn', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => Promise.resolve(user)); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + expect(wrappedApiFn).to.equal(apiFn); + }); + }); + + describe('for READ operations', () => { + it('wraps the function', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => Promise.resolve(user)); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + expect(wrappedApiFn).not.to.equal(apiFn); + }); + + it('makes several calls when apiFn is called with different args', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => Promise.resolve(user)); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + return Promise.all([ + wrappedApiFn('x'), + wrappedApiFn('y') + ]).then(() => { + expect(wrappedApiFn).to.have.been.calledTwice(); + }); + }); + + it('only makes one call when apiFn is called in identical args', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + return Promise.all([ + wrappedApiFn('x'), + wrappedApiFn('x') + ]).then(() => { + expect(wrappedApiFn).to.have.been.calledOnce(); + }); + }); + + it('passes the result of the single call to all callees', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + return Promise.all([ + wrappedApiFn(), + wrappedApiFn() + ]).then((res) => { + expect(res[0]).to.equal(user); + expect(res[1]).to.equal(user); + }); + }); + + it('makes subsequent calls if another calls is made after the first one is resolved', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + + return wrappedApiFn().then(() => { + return wrappedApiFn().then(() => { + expect(wrappedApiFn).to.have.been.calledTwice(); + }); + }); + }); + + // it('propagates errors to all callees', () => { + // const user = { id: 'x' }; + // const apiFn = sinon.spy(delay(() => Promise.reject(user))); + // const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + // }); + }); +}); From 88c4a6c47c8668f3a0c01381e5c7ad077e920373 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:27:41 +0100 Subject: [PATCH 046/136] First green dedup spec --- src/dedup/index.js | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/dedup/index.js diff --git a/src/dedup/index.js b/src/dedup/index.js new file mode 100644 index 0000000..0555b70 --- /dev/null +++ b/src/dedup/index.js @@ -0,0 +1,5 @@ +export const dedup = () => ({ apiFnName, apiFn }) => { + if (apiFn.operation !== 'READ') { + return apiFn; + } +} From b8a464d6de7b125ae167ec13c2bd6d30193539bb Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:28:58 +0100 Subject: [PATCH 047/136] Update dedup specs with operation annotations --- src/dedup/index.spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index afe0208..c6ed490 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -10,6 +10,7 @@ describe('dedup', () => { it('just returns the original apiFn', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve(user)); + apiFn.operation = 'UPDATE'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); expect(wrappedApiFn).to.equal(apiFn); }); @@ -19,6 +20,7 @@ describe('dedup', () => { it('wraps the function', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve(user)); + apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); expect(wrappedApiFn).not.to.equal(apiFn); }); @@ -26,6 +28,7 @@ describe('dedup', () => { it('makes several calls when apiFn is called with different args', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve(user)); + apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn('x'), @@ -38,6 +41,7 @@ describe('dedup', () => { it('only makes one call when apiFn is called in identical args', () => { const user = { id: 'x' }; const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn('x'), @@ -50,6 +54,7 @@ describe('dedup', () => { it('passes the result of the single call to all callees', () => { const user = { id: 'x' }; const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn(), @@ -63,6 +68,7 @@ describe('dedup', () => { it('makes subsequent calls if another calls is made after the first one is resolved', () => { const user = { id: 'x' }; const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return wrappedApiFn().then(() => { From 4d48d557da5c7bceaaf3d903366a51f3f41f280f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:53:37 +0100 Subject: [PATCH 048/136] Fix dedup specs --- src/dedup/index.spec.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index c6ed490..3858903 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -1,3 +1,5 @@ +/* eslint-disable no-unused-expressions */ + import sinon from 'sinon'; import { dedup } from '.'; @@ -5,11 +7,11 @@ const delay = (cb, t = 5) => new Promise((res, rej) => { setTimeout(() => cb().then(res, rej), t); }); -describe('dedup', () => { +fdescribe('dedup', () => { describe('for operations other than READ', () => { it('just returns the original apiFn', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => Promise.resolve(user)); + const apiFn = sinon.spy(() => Promise.resolve({ ...user })); apiFn.operation = 'UPDATE'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); expect(wrappedApiFn).to.equal(apiFn); @@ -27,53 +29,54 @@ describe('dedup', () => { it('makes several calls when apiFn is called with different args', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => Promise.resolve(user)); + const apiFn = sinon.spy(() => Promise.resolve({ ...user })); apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('y') ]).then(() => { - expect(wrappedApiFn).to.have.been.calledTwice(); + expect(apiFn).to.have.been.calledTwice; }); }); it('only makes one call when apiFn is called in identical args', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('x') ]).then(() => { - expect(wrappedApiFn).to.have.been.calledOnce(); + expect(apiFn).to.have.been.calledOnce; }); }); it('passes the result of the single call to all callees', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return Promise.all([ wrappedApiFn(), wrappedApiFn() ]).then((res) => { - expect(res[0]).to.equal(user); - expect(res[1]).to.equal(user); + expect(res[0]).to.deep.equal(user); + expect(res[1]).to.deep.equal(user); + expect(res[0]).to.equal(res[1]); }); }); it('makes subsequent calls if another calls is made after the first one is resolved', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(delay(() => Promise.resolve(user))); + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); return wrappedApiFn().then(() => { return wrappedApiFn().then(() => { - expect(wrappedApiFn).to.have.been.calledTwice(); + expect(apiFn).to.have.been.calledTwice; }); }); }); From 9c6a2a816ae7a0eb4b87820f5d58eca12fa7c0fe Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 20:53:58 +0100 Subject: [PATCH 049/136] Implement dedup --- src/dedup/index.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/dedup/index.js b/src/dedup/index.js index 0555b70..efbd9b6 100644 --- a/src/dedup/index.js +++ b/src/dedup/index.js @@ -1,5 +1,19 @@ -export const dedup = () => ({ apiFnName, apiFn }) => { - if (apiFn.operation !== 'READ') { - return apiFn; - } -} +const toKey = (args) => JSON.stringify(args); + +export const dedup = () => ({ apiFn }) => { + if (apiFn.operation !== 'READ') { return apiFn; } + const cache = {}; + + return (...args) => { + const key = toKey(args); + const cached = cache[key]; + if (cached) { return cached; } + + const promise = apiFn(...args); + cache[key] = promise; + return promise.then((res) => { + delete cache[key]; + return res; + }); + }; +}; From f74f19ce27d6a70980406d1a75c331927061bb99 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:00:44 +0100 Subject: [PATCH 050/136] Add spec for rejection case --- src/dedup/index.spec.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 3858903..d9d89da 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -81,10 +81,17 @@ fdescribe('dedup', () => { }); }); - // it('propagates errors to all callees', () => { - // const user = { id: 'x' }; - // const apiFn = sinon.spy(delay(() => Promise.reject(user))); - // const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); - // }); + it('propagates errors to all callees', () => { + const error = { error: 'ERROR' }; + const apiFn = sinon.spy(() => delay(() => Promise.reject(error))); + const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + return Promise.all([ + wrappedApiFn().catch((err) => err), + wrappedApiFn().catch((err) => err) + ]).then((res) => { + expect(res[0]).to.equal(error); + expect(res[0]).to.equal(res[1]); + }); + }); }); }); From 294de2b30fe32cba2d0e5031eeac3700bab8f4e9 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:04:32 +0100 Subject: [PATCH 051/136] Simplify specs --- src/dedup/index.spec.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index d9d89da..8a6a2d9 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -13,7 +13,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve({ ...user })); apiFn.operation = 'UPDATE'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); expect(wrappedApiFn).to.equal(apiFn); }); }); @@ -23,7 +23,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve(user)); apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); expect(wrappedApiFn).not.to.equal(apiFn); }); @@ -31,7 +31,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => Promise.resolve({ ...user })); apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('y') @@ -44,7 +44,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('x') @@ -57,7 +57,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); return Promise.all([ wrappedApiFn(), wrappedApiFn() @@ -72,7 +72,7 @@ fdescribe('dedup', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); return wrappedApiFn().then(() => { return wrappedApiFn().then(() => { @@ -84,7 +84,7 @@ fdescribe('dedup', () => { it('propagates errors to all callees', () => { const error = { error: 'ERROR' }; const apiFn = sinon.spy(() => delay(() => Promise.reject(error))); - const wrappedApiFn = dedup({})({ apiFn, apiFnName: 'apiFn' }); + const wrappedApiFn = dedup({})({ apiFn }); return Promise.all([ wrappedApiFn().catch((err) => err), wrappedApiFn().catch((err) => err) From 738584149b6a7cef5d9e0cee0367a0344507f8c5 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:11:38 +0100 Subject: [PATCH 052/136] Allow to disable deduplication --- src/dedup/index.js | 10 ++++++++- src/dedup/index.spec.js | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/dedup/index.js b/src/dedup/index.js index efbd9b6..1e3c1fa 100644 --- a/src/dedup/index.js +++ b/src/dedup/index.js @@ -1,10 +1,18 @@ +import { reduce } from '../fp'; + const toKey = (args) => JSON.stringify(args); -export const dedup = () => ({ apiFn }) => { +const isActive = reduce((active, conf = {}) => active && !conf.noDedup, true); + +export const dedup = ({ config }) => ({ entity, apiFn }) => { if (apiFn.operation !== 'READ') { return apiFn; } const cache = {}; return (...args) => { + if (!isActive([config, entity, apiFn])) { + return apiFn(...args); + } + const key = toKey(args); const cached = cache[key]; if (cached) { return cached; } diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 8a6a2d9..2edf6e6 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -93,5 +93,50 @@ fdescribe('dedup', () => { expect(res[0]).to.equal(res[1]); }); }); + + it('can be disabled on a global level', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + apiFn.operation = 'READ'; + const config = { noDedup: true }; + const wrappedApiFn = dedup({ config })({ apiFn }); + + return Promise.all([ + wrappedApiFn(), + wrappedApiFn() + ]).then(() => { + expect(apiFn).to.have.been.calledTwice; + }); + }); + + it('can be disabled on an entity level', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + apiFn.operation = 'READ'; + const entity = { noDedup: true }; + const wrappedApiFn = dedup({})({ apiFn, entity }); + + return Promise.all([ + wrappedApiFn(), + wrappedApiFn() + ]).then(() => { + expect(apiFn).to.have.been.calledTwice; + }); + }); + + it('can be disabled on a function level', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + apiFn.operation = 'READ'; + apiFn.noDedup = true; + const wrappedApiFn = dedup({})({ apiFn }); + + return Promise.all([ + wrappedApiFn(), + wrappedApiFn() + ]).then(() => { + expect(apiFn).to.have.been.calledTwice; + }); + }); }); }); From 488ee0a230581265bd1a53842f61d8e53ca32d99 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:11:48 +0100 Subject: [PATCH 053/136] Unfocus spec --- src/dedup/index.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 2edf6e6..8a1471e 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -7,7 +7,7 @@ const delay = (cb, t = 5) => new Promise((res, rej) => { setTimeout(() => cb().then(res, rej), t); }); -fdescribe('dedup', () => { +describe('dedup', () => { describe('for operations other than READ', () => { it('just returns the original apiFn', () => { const user = { id: 'x' }; From 6525154c59ff8e192fa78ab56e891e5fbc739c36 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:14:27 +0100 Subject: [PATCH 054/136] Add dedup plugin at the end of the plugin chain by default --- src/builder.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index 1da2539..732e920 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,6 +1,7 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; import {decorator} from './decorator'; +import {dedup} from './dedup'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -118,5 +119,5 @@ const applyPlugin = curry((config, entityConfigs, plugin) => { export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const createApi = compose(toApi, reduce(applyPlugin(config), getEntityConfigs(c))); - return createApi([decorator, ...ps]); + return createApi([decorator, ...ps, dedup]); }; From 19017fb3664bb997e44193ec45d68ea8f43be41b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 14 Mar 2017 21:33:29 +0100 Subject: [PATCH 055/136] Add spec for complex arguments --- src/dedup/index.spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 8a1471e..9e1ef20 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -53,6 +53,19 @@ describe('dedup', () => { }); }); + it('detects complex arguments properly', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + apiFn.operation = 'READ'; + const wrappedApiFn = dedup({})({ apiFn }); + return Promise.all([ + wrappedApiFn({ a: 1, b: [2, 3] }, 'a'), + wrappedApiFn({ a: 1, b: [2, 3] }, 'a') + ]).then(() => { + expect(apiFn).to.have.been.calledOnce; + }); + }); + it('passes the result of the single call to all callees', () => { const user = { id: 'x' }; const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); From b008851d907ca6eb6e19638f9a23dabfa3e1ea45 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 07:20:23 +0100 Subject: [PATCH 056/136] Make sure dedup deals correction with rejections --- src/dedup/index.js | 7 ++++++- src/dedup/index.spec.js | 13 +++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/dedup/index.js b/src/dedup/index.js index 1e3c1fa..f57018b 100644 --- a/src/dedup/index.js +++ b/src/dedup/index.js @@ -19,9 +19,14 @@ export const dedup = ({ config }) => ({ entity, apiFn }) => { const promise = apiFn(...args); cache[key] = promise; + const cleanup = () => delete cache[key]; + return promise.then((res) => { - delete cache[key]; + cleanup(); return res; + }, (err) => { + cleanup(); + return Promise.reject(err); }); }; }; diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 9e1ef20..96fef5e 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -94,6 +94,19 @@ describe('dedup', () => { }); }); + it('also makes subsequent calls after the first one is rejected', () => { + const user = { id: 'x' }; + const apiFn = sinon.spy(() => delay(() => Promise.reject({ ...user }))); + apiFn.operation = 'READ'; + const wrappedApiFn = dedup({})({ apiFn }); + + return wrappedApiFn().catch(() => { + return wrappedApiFn().catch(() => { + expect(apiFn).to.have.been.calledTwice; + }); + }); + }); + it('propagates errors to all callees', () => { const error = { error: 'ERROR' }; const apiFn = sinon.spy(() => delay(() => Promise.reject(error))); From 2a413ba1ad2c5f2deb2970042f3ae64fb1b2e32e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 20:36:12 +0100 Subject: [PATCH 057/136] refactor(dedup) Update to simplified plugin api --- src/dedup/index.js | 10 ++--- src/dedup/index.spec.js | 92 ++++++++++++++++++++--------------------- 2 files changed, 51 insertions(+), 51 deletions(-) diff --git a/src/dedup/index.js b/src/dedup/index.js index f57018b..a02507b 100644 --- a/src/dedup/index.js +++ b/src/dedup/index.js @@ -4,20 +4,20 @@ const toKey = (args) => JSON.stringify(args); const isActive = reduce((active, conf = {}) => active && !conf.noDedup, true); -export const dedup = ({ config }) => ({ entity, apiFn }) => { - if (apiFn.operation !== 'READ') { return apiFn; } +export const dedup = ({ config }) => ({ entity, fn }) => { + if (fn.operation !== 'READ') { return fn; } const cache = {}; return (...args) => { - if (!isActive([config, entity, apiFn])) { - return apiFn(...args); + if (!isActive([config, entity, fn])) { + return fn(...args); } const key = toKey(args); const cached = cache[key]; if (cached) { return cached; } - const promise = apiFn(...args); + const promise = fn(...args); cache[key] = promise; const cleanup = () => delete cache[key]; diff --git a/src/dedup/index.spec.js b/src/dedup/index.spec.js index 96fef5e..584fcea 100644 --- a/src/dedup/index.spec.js +++ b/src/dedup/index.spec.js @@ -11,66 +11,66 @@ describe('dedup', () => { describe('for operations other than READ', () => { it('just returns the original apiFn', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => Promise.resolve({ ...user })); - apiFn.operation = 'UPDATE'; - const wrappedApiFn = dedup({})({ apiFn }); - expect(wrappedApiFn).to.equal(apiFn); + const fn = sinon.spy(() => Promise.resolve({ ...user })); + fn.operation = 'UPDATE'; + const wrappedApiFn = dedup({})({ fn }); + expect(wrappedApiFn).to.equal(fn); }); }); describe('for READ operations', () => { it('wraps the function', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => Promise.resolve(user)); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); - expect(wrappedApiFn).not.to.equal(apiFn); + const fn = sinon.spy(() => Promise.resolve(user)); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); + expect(wrappedApiFn).not.to.equal(fn); }); it('makes several calls when apiFn is called with different args', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => Promise.resolve({ ...user })); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => Promise.resolve({ ...user })); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('y') ]).then(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); it('only makes one call when apiFn is called in identical args', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn('x'), wrappedApiFn('x') ]).then(() => { - expect(apiFn).to.have.been.calledOnce; + expect(fn).to.have.been.calledOnce; }); }); it('detects complex arguments properly', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn({ a: 1, b: [2, 3] }, 'a'), wrappedApiFn({ a: 1, b: [2, 3] }, 'a') ]).then(() => { - expect(apiFn).to.have.been.calledOnce; + expect(fn).to.have.been.calledOnce; }); }); it('passes the result of the single call to all callees', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn(), wrappedApiFn() @@ -83,34 +83,34 @@ describe('dedup', () => { it('makes subsequent calls if another calls is made after the first one is resolved', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return wrappedApiFn().then(() => { return wrappedApiFn().then(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); }); it('also makes subsequent calls after the first one is rejected', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.reject({ ...user }))); - apiFn.operation = 'READ'; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.reject({ ...user }))); + fn.operation = 'READ'; + const wrappedApiFn = dedup({})({ fn }); return wrappedApiFn().catch(() => { return wrappedApiFn().catch(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); }); it('propagates errors to all callees', () => { const error = { error: 'ERROR' }; - const apiFn = sinon.spy(() => delay(() => Promise.reject(error))); - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.reject(error))); + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn().catch((err) => err), wrappedApiFn().catch((err) => err) @@ -122,46 +122,46 @@ describe('dedup', () => { it('can be disabled on a global level', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; const config = { noDedup: true }; - const wrappedApiFn = dedup({ config })({ apiFn }); + const wrappedApiFn = dedup({ config })({ fn }); return Promise.all([ wrappedApiFn(), wrappedApiFn() ]).then(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); it('can be disabled on an entity level', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; const entity = { noDedup: true }; - const wrappedApiFn = dedup({})({ apiFn, entity }); + const wrappedApiFn = dedup({})({ fn, entity }); return Promise.all([ wrappedApiFn(), wrappedApiFn() ]).then(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); it('can be disabled on a function level', () => { const user = { id: 'x' }; - const apiFn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); - apiFn.operation = 'READ'; - apiFn.noDedup = true; - const wrappedApiFn = dedup({})({ apiFn }); + const fn = sinon.spy(() => delay(() => Promise.resolve({ ...user }))); + fn.operation = 'READ'; + fn.noDedup = true; + const wrappedApiFn = dedup({})({ fn }); return Promise.all([ wrappedApiFn(), wrappedApiFn() ]).then(() => { - expect(apiFn).to.have.been.calledTwice; + expect(fn).to.have.been.calledTwice; }); }); }); From bc562d8638b2bde0413f9d383376bd568e28b0c5 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Wed, 15 Mar 2017 07:50:48 +0100 Subject: [PATCH 058/136] Extract toIdMap to fp --- src/fp.js | 2 ++ src/fp.spec.js | 14 +++++++++++++- src/plugins/denormalizer/index.js | 6 ++---- src/plugins/denormalizer/index.spec.js | 4 +--- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/fp.js b/src/fp.js index 9d0cc3b..5e94f13 100644 --- a/src/fp.js +++ b/src/fp.js @@ -182,3 +182,5 @@ export const uniq = (arr) => [...new Set(arr)]; export const fst = (arr) => arr[0]; export const snd = (arr) => arr[1]; +export const toIdMap = toObject(prop('id')); + diff --git a/src/fp.spec.js b/src/fp.spec.js index eccf839..011d001 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -6,7 +6,7 @@ import {debug, identity, curry, passThrough, on2, init, tail, last, head, map, map_, reverse, reduce, compose, prop, zip, flip, toPairs, fromPairs, mapObject, mapValues, toObject, filter, clone, filterObject, - copyFunction, get, set, concat, flatten, uniq} from './fp'; + copyFunction, get, set, concat, flatten, uniq, toIdMap} from './fp'; describe('fp', () => { describe('debug', () => { @@ -347,4 +347,16 @@ describe('fp', () => { expect(actual).to.deep.equal(expected); }); }); + + describe('toIdMap', () => { + it('returns a map with ids as keys from a list of entities', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + + const expected = { a, b, c }; + const actual = toIdMap([a, b, c]); + expect(actual).to.deep.equal(expected); + }); + }); }); diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index 6e0891a..c063364 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -1,7 +1,7 @@ import { compose, curry, head, map, mapObject, mapValues, - prop, reduce, fromPairs, toPairs, toObject, values, - uniq, flatten, get, set, snd + prop, reduce, fromPairs, toPairs, values, + uniq, flatten, get, set, snd, toIdMap } from '../../fp'; /* TYPES @@ -23,8 +23,6 @@ export const NAME = 'denormalizer'; const def = curry((a, b) => b || a); -const toIdMap = toObject(prop('id')); - const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); const getPluginConf_ = curry((config) => compose(prop(NAME), def({}), prop('plugins'))(config)); diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index de0cbfb..5bc4f85 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -3,11 +3,9 @@ import sinon from 'sinon'; import { build } from '../../builder'; -import { curry, prop, head, last, toObject, values } from '../../fp'; +import { curry, head, last, toIdMap, values } from '../../fp'; import { denormalizer, extractAccessors } from '.'; -const toIdMap = toObject(prop('id')); - const peter = { id: 'peter' }; const gernot = { id: 'gernot' }; const robin = { id: 'robin' }; From 9aa17244cc006ffec02db6b2d9090abc62558131 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 07:50:43 +0100 Subject: [PATCH 059/136] Add mPut to entityStore --- src/entity-store.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/entity-store.js b/src/entity-store.js index 156510c..077a136 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -97,6 +97,9 @@ const setViewValue = (s, e, v) => { // EntityStore -> Entity -> Value -> () export const put = handle(setViewValue, setEntityValue); +// EntityStore -> Entity -> [Value] -> () +export const mPut = curry((es, e) => map_(put(es, e))); + // EntityStore -> Entity -> String -> Value const getEntityValue = (s, e, id) => { const k = createEntityKey(e, {__ladda__id: id}); From d2f73fb5e2ad83829414d4698b81cfc8967e38e3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 07:51:25 +0100 Subject: [PATCH 060/136] Add byIds default values --- src/builder.js | 3 ++- src/test-helper.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index 732e920..9729edf 100644 --- a/src/builder.js +++ b/src/builder.js @@ -81,7 +81,8 @@ const setApiConfigDefaults = ec => { operation: 'NO_OPERATION', invalidates: [], idFrom: 'ENTITY', - byId: false + byId: false, + byIds: false }; const writeToObjectIfNotSet = curry((o, [k, v]) => { diff --git a/src/test-helper.js b/src/test-helper.js index edcff02..d732ce4 100644 --- a/src/test-helper.js +++ b/src/test-helper.js @@ -6,6 +6,7 @@ export const createApiFunction = (fn, config = {}) => { fnCopy.invalidates = config.invalidates || []; fnCopy.idFrom = config.idFrom || 'ENTITY'; fnCopy.byId = config.byId || false; + fnCopy.byIds = config.byIds || false; return fnCopy; }; From 8b4fe164f6d2f91febe5bc691b272972dc650a88 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 07:51:39 +0100 Subject: [PATCH 061/136] Add specs for byIds config option --- src/decorator/read.spec.js | 76 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/decorator/read.spec.js b/src/decorator/read.spec.js index 56cfef1..aaff051 100644 --- a/src/decorator/read.spec.js +++ b/src/decorator/read.spec.js @@ -5,6 +5,7 @@ import {decorateRead} from './read'; import {createEntityStore} from '../entity-store'; import {createQueryCache} from '../query-cache'; import {createSampleConfig, createApiFunction} from '../test-helper'; +import {map} from '../fp'; const config = createSampleConfig(); @@ -78,6 +79,81 @@ describe('Read', () => { done(); }); }); + fdescribe('with byIds', () => { + const users = { + a: { id: 'a' }, + b: { id: 'b' }, + c: { id: 'c' } + }; + + const fn = (ids) => Promise.resolve(map((id) => users[id], ids)); + const decoratedFn = createApiFunction(fn, { byIds: true }); + + it('calls api fn if nothing in cache', () => { + const es = createEntityStore(config); + const qc = createQueryCache(es); + const e = config[0]; + const fnWithSpy = sinon.spy(decoratedFn); + const apiFn = decorateRead({}, es, qc, e, fnWithSpy); + return apiFn(['a', 'b']).then((res) => { + expect(res).to.deep.equal([users.a, users.b]); + }); + }); + + it('calls api fn if nothing in cache', () => { + const es = createEntityStore(config); + const qc = createQueryCache(es); + const e = config[0]; + const fnWithSpy = sinon.spy(decoratedFn); + const apiFn = decorateRead({}, es, qc, e, fnWithSpy); + return apiFn(['a', 'b']).then((res) => { + expect(res).to.deep.equal([users.a, users.b]); + }); + }); + + it('puts item in the cache and can read them again', () => { + const es = createEntityStore(config); + const qc = createQueryCache(es); + const e = config[0]; + const fnWithSpy = sinon.spy(decoratedFn); + const apiFn = decorateRead({}, es, qc, e, fnWithSpy); + const args = ['a', 'b']; + return apiFn(args).then(() => { + return apiFn(args).then((res) => { + expect(fnWithSpy).to.have.been.calledOnce; + expect(res).to.deep.equal([users.a, users.b]); + }); + }); + }); + + it('only makes additional request for uncached items', () => { + const es = createEntityStore(config); + const qc = createQueryCache(es); + const e = config[0]; + const fnWithSpy = sinon.spy(decoratedFn); + const apiFn = decorateRead({}, es, qc, e, fnWithSpy); + return apiFn(['a', 'b']).then(() => { + return apiFn(['b', 'c']).then(() => { + expect(fnWithSpy).to.have.been.calledTwice; + expect(fnWithSpy).to.have.been.calledWith(['a', 'b']); + expect(fnWithSpy).to.have.been.calledWith(['c']); + }); + }); + }); + + it('returns all items in correct order when making partial requests', () => { + const es = createEntityStore(config); + const qc = createQueryCache(es); + const e = config[0]; + const fnWithSpy = sinon.spy(decoratedFn); + const apiFn = decorateRead({}, es, qc, e, fnWithSpy); + return apiFn(['a', 'b']).then(() => { + return apiFn(['a', 'b', 'c']).then((res) => { + expect(res).to.deep.equal([users.a, users.b, users.c]); + }); + }); + }); + }); it('calls api fn if not in cache', (done) => { const es = createEntityStore(config); const qc = createQueryCache(es); From ea40c6bfd9b02bd9e77b4a2a3209096fa0a69102 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 07:51:49 +0100 Subject: [PATCH 062/136] Implement byIds --- src/decorator/read.js | 53 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/decorator/read.js b/src/decorator/read.js index 9b3797e..5c7e519 100644 --- a/src/decorator/read.js +++ b/src/decorator/read.js @@ -1,12 +1,13 @@ import {get as getFromEs, put as putInEs, + mPut as mPutInEs, contains as inEs} from '../entity-store'; import {get as getFromQc, invalidate, put as putInQc, contains as inQc, getValue} from '../query-cache'; -import {passThrough, compose} from '../fp'; +import {passThrough, compose, curry, reduce, toIdMap, map, concat, zip} from '../fp'; import {addId, removeId} from '../id-helper'; const getTtl = e => e.ttl * 1000; @@ -16,13 +17,21 @@ const hasExpired = (e, timestamp) => { return (Date.now() - timestamp) > getTtl(e); }; +const readFromCache = curry((es, e, aFn, id) => { + if (inEs(es, e, id) && !aFn.alwaysGetFreshData) { + const v = getFromEs(es, e, id); + if (!hasExpired(e, v.timestamp)) { + return removeId(v.value); + } + } + return undefined; +}); + const decorateReadSingle = (c, es, qc, e, aFn) => { return (id) => { - if (inEs(es, e, id) && !aFn.alwaysGetFreshData) { - const v = getFromEs(es, e, id); - if (!hasExpired(e, v.timestamp)) { - return Promise.resolve(removeId(v.value)); - } + const fromCache = readFromCache(es, e, aFn, id); + if (fromCache) { + return Promise.resolve(fromCache); } return aFn(id) @@ -31,6 +40,35 @@ const decorateReadSingle = (c, es, qc, e, aFn) => { }; }; +const decorateReadSome = (c, es, qc, e, aFn) => { + return (ids) => { + const readFromCache_ = readFromCache(es, e, aFn); + const [cached, remaining] = reduce(([c_, r], id) => { + const fromCache = readFromCache_(id); + if (fromCache) { + c_.push(fromCache); + } else { + r.push(id); + } + return [c_, r]; + }, [[], []], ids); + + if (!remaining.length) { + return Promise.resolve(cached); + } + + const addIds = map(([id, item]) => addId(c, aFn, id, item)); + + return aFn(remaining) + .then(passThrough(compose(mPutInEs(es, e), addIds, zip(remaining)))) + .then(passThrough(() => invalidate(qc, e, aFn))) + .then((other) => { + const asMap = compose(toIdMap, concat)(cached, other); + return map((id) => asMap[id], ids); + }); + }; +}; + const decorateReadQuery = (c, es, qc, e, aFn) => { return (...args) => { if (inQc(qc, e, aFn, args) && !aFn.alwaysGetFreshData) { @@ -50,5 +88,8 @@ export function decorateRead(c, es, qc, e, aFn) { if (aFn.byId) { return decorateReadSingle(c, es, qc, e, aFn); } + if (aFn.byIds) { + return decorateReadSome(c, es, qc, e, aFn); + } return decorateReadQuery(c, es, qc, e, aFn); } From 3c10075603aa9b60e9b57b1706a41045c8c46c4b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 08:12:15 +0100 Subject: [PATCH 063/136] Unfocus spec --- src/decorator/read.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/decorator/read.spec.js b/src/decorator/read.spec.js index aaff051..d0071c1 100644 --- a/src/decorator/read.spec.js +++ b/src/decorator/read.spec.js @@ -79,7 +79,7 @@ describe('Read', () => { done(); }); }); - fdescribe('with byIds', () => { + describe('with byIds', () => { const users = { a: { id: 'a' }, b: { id: 'b' }, From dbc303511c0fab6823a37ef21947327390a60b1f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 20:44:34 +0100 Subject: [PATCH 064/136] refactor(query-cache) Prefer mPut over put in entityStore --- src/entity-store.js | 2 +- src/query-cache.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/entity-store.js b/src/entity-store.js index 077a136..7f83a26 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -98,7 +98,7 @@ const setViewValue = (s, e, v) => { export const put = handle(setViewValue, setEntityValue); // EntityStore -> Entity -> [Value] -> () -export const mPut = curry((es, e) => map_(put(es, e))); +export const mPut = curry((es, e, xs) => map_(put(es, e))(xs)); // EntityStore -> Entity -> String -> Value const getEntityValue = (s, e, id) => { diff --git a/src/query-cache.js b/src/query-cache.js index ac83ac8..60beea5 100644 --- a/src/query-cache.js +++ b/src/query-cache.js @@ -3,7 +3,7 @@ * Only ids are stored here. */ -import {put as putInEs, get as getFromEs} from './entity-store'; +import {mPut as mPutInEs, get as getFromEs} from './entity-store'; import {on2, prop, join, reduce, identity, curry, map, map_, startsWith, compose, filter} from './fp'; import {serialize} from './serializer'; @@ -41,7 +41,7 @@ export const put = curry((qc, e, aFn, args, xs) => { } else { qc.cache[k] = toCacheValue(prop('__ladda__id', xs)); } - map_(putInEs(qc.entityStore, e), Array.isArray(xs) ? xs : [xs]); + mPutInEs(qc.entityStore, e, Array.isArray(xs) ? xs : [xs]); return xs; }); From d44bb223a9d35ea005f7b688142821bc11675ff8 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 20:52:20 +0100 Subject: [PATCH 065/136] refactor(entity-store) Let put use mPut, which is not the primary source of updates --- src/entity-store.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/entity-store.js b/src/entity-store.js index 7f83a26..b666515 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -94,11 +94,11 @@ const setViewValue = (s, e, v) => { return v; }; -// EntityStore -> Entity -> Value -> () -export const put = handle(setViewValue, setEntityValue); - // EntityStore -> Entity -> [Value] -> () -export const mPut = curry((es, e, xs) => map_(put(es, e))(xs)); +export const mPut = curry((es, e, xs) => map_(handle(setViewValue, setEntityValue)(es, e))(xs)); + +// EntityStore -> Entity -> Value -> () +export const put = curry((es, e, x) => mPut(es, e, [x])); // EntityStore -> Entity -> String -> Value const getEntityValue = (s, e, id) => { From 21e02c2ab6017681caebf8c65fd4e379aa422fd8 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 21:01:28 +0100 Subject: [PATCH 066/136] feat(entity-store) Add mPut spec --- src/entity-store.spec.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/entity-store.spec.js b/src/entity-store.spec.js index dabeaff..087179b 100644 --- a/src/entity-store.spec.js +++ b/src/entity-store.spec.js @@ -1,6 +1,6 @@ /* eslint-disable no-unused-expressions */ -import {createEntityStore, put, get, contains, remove} from './entity-store'; +import {createEntityStore, put, mPut, get, contains, remove} from './entity-store'; import {addId} from './id-helper'; const config = [ @@ -106,6 +106,22 @@ describe('EntityStore', () => { expect(write).to.throw(Error); }); }); + + describe('mPut', () => { + it('adds values which are later returned when calling get', () => { + const s = createEntityStore(config); + const v1 = {id: 'hello'}; + const v2 = {id: 'there'}; + const e = { name: 'user'}; + const v1WithId = addId({}, undefined, undefined, v1); + const v2WithId = addId({}, undefined, undefined, v2); + mPut(s, e, [v1WithId, v2WithId]); + const r1 = get(s, e, v1.id); + const r2 = get(s, e, v2.id); + expect(r1.value).to.deep.equal({...v1, __ladda__id: 'hello'}); + expect(r2.value).to.deep.equal({...v2, __ladda__id: 'there'}); + }); + }); describe('get', () => { it('gets value with timestamp', () => { const s = createEntityStore(config); From 189d428bfe54f2501cfd5e048c4e4c44124cdb0e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 21:28:27 +0100 Subject: [PATCH 067/136] feat(entity-store) Add hook specs --- src/entity-store.spec.js | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/entity-store.spec.js b/src/entity-store.spec.js index 087179b..dcb326d 100644 --- a/src/entity-store.spec.js +++ b/src/entity-store.spec.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-expressions */ +import sinon from 'sinon'; import {createEntityStore, put, mPut, get, contains, remove} from './entity-store'; import {addId} from './id-helper'; @@ -231,4 +232,61 @@ describe('EntityStore', () => { expect(fn).to.not.throw(); }); }); + + describe('with a hook', () => { + describe('put', () => { + it('notifies with the put entity as singleton list', () => { + const hook = sinon.spy(); + const s = createEntityStore(config, hook); + const v = {id: 'hello'}; + const e = { name: 'user'}; + put(s, e, addId({}, undefined, undefined, v)); + expect(hook).to.have.been.called; + expect(hook).to.have.been.calledWith({ + type: 'UPDATE', + entities: [v] + }); + + const arg = hook.args[0][0]; + expect(arg.type).to.equal('UPDATE'); + expect(arg.entities).to.deep.equal([v]); + }); + }); + + describe('mPut', () => { + it('notifies with the put entities', () => { + const hook = sinon.spy(); + const s = createEntityStore(config, hook); + const v1 = {id: 'hello'}; + const v2 = {id: 'there'}; + const e = { name: 'user'}; + const v1WithId = addId({}, undefined, undefined, v1); + const v2WithId = addId({}, undefined, undefined, v2); + mPut(s, e, [v1WithId, v2WithId]); + + expect(hook).to.have.been.called; + + const arg = hook.args[0][0]; + expect(arg.type).to.equal('UPDATE'); + expect(arg.entities).to.deep.equal([v1, v2]); + }); + }); + + describe('rm', () => { + it('notifies with the removed entity as a singleton list', () => { + const hook = sinon.spy(); + const s = createEntityStore(config, hook); + const v = {id: 'hello'}; + const e = { name: 'user'}; + put(s, e, addId({}, undefined, undefined, v)); + remove(s, e, v.id); + + expect(hook).to.have.been.calledTwice; // we also put! + + const arg = hook.args[1][0]; + expect(arg.type).to.equal('DELETE'); + expect(arg.entities).to.deep.equal([v]); + }); + }); + }); }); From c55e5d4cfa3722ddacb2e310a103abe06bf1a8c6 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 22:02:20 +0100 Subject: [PATCH 068/136] feat(fp) Add noop --- src/fp.js | 1 + src/fp.spec.js | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/fp.js b/src/fp.js index 5e94f13..0d1eaa6 100644 --- a/src/fp.js +++ b/src/fp.js @@ -184,3 +184,4 @@ export const snd = (arr) => arr[1]; export const toIdMap = toObject(prop('id')); +export const noop = () => {}; diff --git a/src/fp.spec.js b/src/fp.spec.js index 011d001..fe37d30 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -2,7 +2,7 @@ import sinon from 'sinon'; import {debug, identity, curry, passThrough, - startsWith, join, on, isEqual, + startsWith, join, on, isEqual, noop, on2, init, tail, last, head, map, map_, reverse, reduce, compose, prop, zip, flip, toPairs, fromPairs, mapObject, mapValues, toObject, filter, clone, filterObject, @@ -359,4 +359,11 @@ describe('fp', () => { expect(actual).to.deep.equal(expected); }); }); + + describe('noop', () => { + it('returns a function that does nothing', () => { + expect(noop).not.to.throw; + expect(noop()).to.be.undefined; + }); + }); }); From 915932d4b8f5ee5d3d8a35feee4db87cf29d473a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Thu, 16 Mar 2017 22:08:32 +0100 Subject: [PATCH 069/136] feat(entity-store) Implement triggering the hook --- src/entity-store.js | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/src/entity-store.js b/src/entity-store.js index b666515..b60018e 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -12,7 +12,14 @@ */ import {merge} from './merger'; -import {curry, reduce, map_, clone} from './fp'; +import {curry, reduce, map_, clone, noop} from './fp'; +import {removeId} from './id-helper'; + +// EntityStore -> Hook +const getHook = (es) => es[2]; + +// EntityStore -> Type -> [Entity] -> () +const triggerHook = (es, type, xs) => getHook(es)({ type, entities: removeId(xs) }); // Value -> StoreValue const toStoreValue = v => ({value: v, timestamp: Date.now()}); @@ -49,12 +56,6 @@ const createViewKey = (e, v) => { // Entity -> Bool const isView = e => !!e.viewOf; -// EntityStore -> Entity -> String -> () -export const remove = (es, e, id) => { - rm(es, createEntityKey(e, {__ladda__id: id})); - rmViews(es, e); -}; - // Function -> Function -> EntityStore -> Entity -> Value -> a const handle = curry((viewHandler, entityHandler, s, e, v) => { if (isView(e)) { @@ -95,7 +96,10 @@ const setViewValue = (s, e, v) => { }; // EntityStore -> Entity -> [Value] -> () -export const mPut = curry((es, e, xs) => map_(handle(setViewValue, setEntityValue)(es, e))(xs)); +export const mPut = curry((es, e, xs) => { + map_(handle(setViewValue, setEntityValue)(es, e))(xs); + triggerHook(es, 'UPDATE', xs); +}); // EntityStore -> Entity -> Value -> () export const put = curry((es, e, x) => mPut(es, e, [x])); @@ -121,28 +125,38 @@ const getViewValue = (s, e, id) => { // EntityStore -> Entity -> String -> () export const get = handle(getViewValue, getEntityValue); +// EntityStore -> Entity -> String -> () +export const remove = (es, e, id) => { + const x = get(es, e, id); + rm(es, createEntityKey(e, {__ladda__id: id})); + rmViews(es, e); + if (x) { + triggerHook(es, 'DELETE', [x.value]); + } +}; + // EntityStore -> Entity -> String -> Bool export const contains = (es, e, id) => !!handle(getViewValue, getEntityValue)(es, e, id); // EntityStore -> Entity -> EntityStore -const registerView = ([eMap, store], e) => { +const registerView = ([eMap, ...other], e) => { if (!eMap[e.viewOf]) { eMap[e.viewOf] = []; } eMap[e.viewOf].push(e.name); - return [eMap, store]; + return [eMap, ...other]; }; // EntityStore -> Entity -> EntityStore -const registerEntity = ([eMap, store], e) => { +const registerEntity = ([eMap, ...other], e) => { if (!eMap[e.name]) { eMap[e.name] = []; } - return [eMap, store]; + return [eMap, ...other]; }; // EntityStore -> Entity -> EntityStore const updateIndex = (m, e) => { return isView(e) ? registerView(m, e) : registerEntity(m, e); }; // [Entity] -> EntityStore -export const createEntityStore = c => reduce(updateIndex, [{}, {}], c); +export const createEntityStore = (c, hook = noop) => reduce(updateIndex, [{}, {}, hook], c); From aa9d2da1d5e422e45253c504d12d4af150ec77c1 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Fri, 17 Mar 2017 01:52:23 +0100 Subject: [PATCH 070/136] feat(entity-store) Record entity names --- src/entity-store.js | 20 ++++++++++++-------- src/entity-store.spec.js | 6 ++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/entity-store.js b/src/entity-store.js index b60018e..e67edc9 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -15,12 +15,6 @@ import {merge} from './merger'; import {curry, reduce, map_, clone, noop} from './fp'; import {removeId} from './id-helper'; -// EntityStore -> Hook -const getHook = (es) => es[2]; - -// EntityStore -> Type -> [Entity] -> () -const triggerHook = (es, type, xs) => getHook(es)({ type, entities: removeId(xs) }); - // Value -> StoreValue const toStoreValue = v => ({value: v, timestamp: Date.now()}); @@ -56,6 +50,16 @@ const createViewKey = (e, v) => { // Entity -> Bool const isView = e => !!e.viewOf; +// EntityStore -> Hook +const getHook = (es) => es[2]; + +// EntityStore -> Type -> [Entity] -> () +const triggerHook = curry((es, e, type, xs) => getHook(es)({ + type, + entity: getEntityType(e), + entities: removeId(xs) +})); + // Function -> Function -> EntityStore -> Entity -> Value -> a const handle = curry((viewHandler, entityHandler, s, e, v) => { if (isView(e)) { @@ -98,7 +102,7 @@ const setViewValue = (s, e, v) => { // EntityStore -> Entity -> [Value] -> () export const mPut = curry((es, e, xs) => { map_(handle(setViewValue, setEntityValue)(es, e))(xs); - triggerHook(es, 'UPDATE', xs); + triggerHook(es, e, 'UPDATE', xs); }); // EntityStore -> Entity -> Value -> () @@ -131,7 +135,7 @@ export const remove = (es, e, id) => { rm(es, createEntityKey(e, {__ladda__id: id})); rmViews(es, e); if (x) { - triggerHook(es, 'DELETE', [x.value]); + triggerHook(es, e, 'DELETE', [x.value]); } }; diff --git a/src/entity-store.spec.js b/src/entity-store.spec.js index dcb326d..2f87cbc 100644 --- a/src/entity-store.spec.js +++ b/src/entity-store.spec.js @@ -242,14 +242,12 @@ describe('EntityStore', () => { const e = { name: 'user'}; put(s, e, addId({}, undefined, undefined, v)); expect(hook).to.have.been.called; + expect(hook).to.have.been.calledWith({ type: 'UPDATE', + entity: 'user', entities: [v] }); - - const arg = hook.args[0][0]; - expect(arg.type).to.equal('UPDATE'); - expect(arg.entities).to.deep.equal([v]); }); }); From d51b223ce1fc64c02dd2901ee1dce6997f813b70 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Fri, 17 Mar 2017 01:52:41 +0100 Subject: [PATCH 071/136] feat(listener-store) Add listener store --- src/listener-store.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/listener-store.js diff --git a/src/listener-store.js b/src/listener-store.js new file mode 100644 index 0000000..c9f98e2 --- /dev/null +++ b/src/listener-store.js @@ -0,0 +1,23 @@ +import { curry, map_ } from './fp'; + +const remove = curry((el, arr) => { + const i = arr.indexOf(el); + if (i !== -1) { arr.splice(i, 1); } + return arr; +}); + +const addListener = curry((listeners, listener) => { + listeners.push(listener); + return () => remove(listener, listeners); +}); + +const notify = curry((listeners, change) => map_((l) => l(change), listeners)); + +export const createListenerStore = () => { + const listeners = []; + return { + onChange: notify(listeners), + addListener: addListener(listeners) + }; +}; + From 48b537ecf57af206039699de7e53960923c93a88 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Fri, 17 Mar 2017 01:53:34 +0100 Subject: [PATCH 072/136] feat(core) Expose listener interface --- src/builder.js | 17 ++++++++++++----- src/decorator/index.js | 6 +++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/builder.js b/src/builder.js index 9729edf..617dda9 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,7 +1,11 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, - prop, filterObject, isEqual, not, curry, copyFunction} from './fp'; + prop, filterObject, isEqual, not, curry, copyFunction, + set + } from './fp'; + import {decorator} from './decorator'; import {dedup} from './dedup'; +import {createListenerStore} from './listener-store'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -111,14 +115,17 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -const applyPlugin = curry((config, entityConfigs, plugin) => { - const pluginDecorator = plugin({ config, entityConfigs }); +const applyPlugin = curry((addListener, config, entityConfigs, plugin) => { + const pluginDecorator = plugin({ addListener, config, entityConfigs }); return mapApiFunctions(pluginDecorator, entityConfigs); }); // Config -> Api export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; - const createApi = compose(toApi, reduce(applyPlugin(config), getEntityConfigs(c))); - return createApi([decorator, ...ps, dedup]); + const listenerStore = createListenerStore(config); + const addListener = set(['__addListener'], listenerStore.addListener); + const applyPlugins = reduce(applyPlugin(addListener, config), getEntityConfigs(c)); + const createApi = compose(addListener, toApi, applyPlugins); + return createApi([decorator(listenerStore.onChange), ...ps, dedup]); }; diff --git a/src/decorator/index.js b/src/decorator/index.js index f061c63..1802e91 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -15,9 +15,9 @@ const HANDLERS = { NO_OPERATION: decorateNoOperation }; -export const decorator = ({ config, entityConfigs }) => { - const entityStore = compose(createEntityStore, values)(entityConfigs); - const queryCache = createQueryCache(entityStore); +export const decorator = (onChange) => ({ config, entityConfigs }) => { + const entityStore = compose((c) => createEntityStore(c, onChange), values)(entityConfigs); + const queryCache = createQueryCache(entityStore, onChange); return ({ entity, fn }) => { const handler = HANDLERS[fn.operation]; return handler(config, entityStore, queryCache, entity, fn); From 24ab3fa7ac7135cdaa141c6ded4d7fde3ade3623 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 10:54:07 +0200 Subject: [PATCH 073/136] Setup specs for subscriber plugin --- src/plugins/subscriber/index.spec.js | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/plugins/subscriber/index.spec.js diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js new file mode 100644 index 0000000..cc4ea39 --- /dev/null +++ b/src/plugins/subscriber/index.spec.js @@ -0,0 +1,56 @@ +/* eslint-disable no-unused-expressions */ + +import sinon from 'sinon'; + +import { build } from '../../builder'; +import { toIdMap, values } from '../../fp'; +import { subscriber as plugin } from '.'; + +const createConfig = () => { + const peter = { id: 'peter', name: 'peter' }; + const gernot = { id: 'gernot', name: 'gernot' }; + const robin = { id: 'robin', name: 'robin' }; + + const users = toIdMap([peter, gernot, robin]); + + const getUser = (id) => Promise.resolve(users[id]); + getUser.operation = 'READ'; + getUser.byId = true; + const getUsers = () => Promise.resolve(values(users)); + getUser.operation = 'READ'; + + const updateUser = (nextUser) => { + const { id } = nextUser; + const user = users[id]; + users[id] = { ...user, ...nextUser }; + return Promise.resolve(users[id]); + }; + updateUser.operaton = 'UPDATE'; + + const removeUser = (id) => { + delete users[id]; + Promise.resolve(); + }; + removeUser.operation = 'DELETE'; + + return { + user: { + api: { getUser, getUsers, updateUser, removeUser } + } + }; +}; + + +describe('subscriber', () => { + it('patches fns so that a subscriber can be created on READ operations', () => { + const api = build(createConfig(), [plugin()]); + expect(api.user.getUsers.createSubscriber).to.be.a('function'); + expect(api.user.getUser.createSubscriber).to.be.a('function'); + }); + + it('does not patch other operations than read', () => { + const api = build(createConfig(), [plugin()]); + expect(api.user.removeUser.createSubscriber).not.to.be; + expect(api.user.updateUser.createSubscriber).not.to.be; + }); +}); From 0029b87f38c25912468bbc3fc0ec8b0eeee38f41 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 11:15:14 +0200 Subject: [PATCH 074/136] Do NOT strip metadata from final api functions --- src/builder.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/builder.js b/src/builder.js index 617dda9..6d09ea4 100644 --- a/src/builder.js +++ b/src/builder.js @@ -64,10 +64,8 @@ export const mapApiFunctions = (fn, entityConfigs) => { }, entityConfigs); }; -const stripMetaData = (fn) => (...args) => fn(...args); - // EntityConfig -> Api -const toApi = mapValues(compose(mapValues(stripMetaData), prop('api'))); +const toApi = mapValues(prop('api')); // EntityConfig -> EntityConfig const setEntityConfigDefaults = ec => { From d3859d0683b9b74bc9dfe8757635f712d46def76 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 11:41:25 +0200 Subject: [PATCH 075/136] Add list removal functions to fp --- src/fp.js | 8 +++++ src/fp.spec.js | 88 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/fp.js b/src/fp.js index 0d1eaa6..cc07029 100644 --- a/src/fp.js +++ b/src/fp.js @@ -185,3 +185,11 @@ export const snd = (arr) => arr[1]; export const toIdMap = toObject(prop('id')); export const noop = () => {}; + +export const removeAtIndex = curry((i, list) => { + const isOutOfBounds = i === -1 || i > list.length - 1; + return isOutOfBounds ? list : [...list.slice(0, i), ...list.slice(i + 1)]; +}); + +export const removeElement = curry((el, list) => removeAtIndex(list.indexOf(el), list)); + diff --git a/src/fp.spec.js b/src/fp.spec.js index fe37d30..b356a3b 100644 --- a/src/fp.spec.js +++ b/src/fp.spec.js @@ -6,7 +6,8 @@ import {debug, identity, curry, passThrough, on2, init, tail, last, head, map, map_, reverse, reduce, compose, prop, zip, flip, toPairs, fromPairs, mapObject, mapValues, toObject, filter, clone, filterObject, - copyFunction, get, set, concat, flatten, uniq, toIdMap} from './fp'; + copyFunction, get, set, concat, flatten, uniq, toIdMap, + removeAtIndex, removeElement} from './fp'; describe('fp', () => { describe('debug', () => { @@ -366,4 +367,89 @@ describe('fp', () => { expect(noop()).to.be.undefined; }); }); + + describe('removeAtIndex', () => { + it('removes an element at a given index', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + const expected = [a, c]; + const actual = removeAtIndex(1, list); + + expect(actual).not.to.equal(list); + expect(actual).to.deep.equal(expected); + }); + + it('does nothing when the index is negative', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + const actual = removeAtIndex(-1, list); + + expect(actual).to.equal(list); + }); + + it('does nothing when the index is out of high bound', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + const actual = removeAtIndex(list.length, list); + + expect(actual).to.equal(list); + }); + + it('is curried', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + const removeAt2 = removeAtIndex(2); + const expected = [a, b]; + const actual = removeAt2(list); + + expect(actual).not.to.equal(list); + expect(actual).to.deep.equal(expected); + }); + }); + + describe('removeElement', () => { + it('removes an element from a list', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + const expected = [a, c]; + const actual = removeElement(b, list); + + expect(actual).not.to.equal(list); + expect(actual).to.deep.equal(expected); + }); + + it('does nothing when the element is not present in the list', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, c]; + const actual = removeElement(b, list); + expect(actual).to.equal(list); + }); + + it('is curried', () => { + const a = { id: 'a' }; + const b = { id: 'b' }; + const c = { id: 'c' }; + const list = [a, b, c]; + + const removeB = removeElement(b); + + const expected = [a, c]; + const actual = removeB(list); + + expect(actual).not.to.equal(list); + expect(actual).to.deep.equal(expected); + }); + }); }); From 1c4accabfd1a299d4a7c2d5fcaa8a3f169e816a2 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 12:08:48 +0200 Subject: [PATCH 076/136] Fix passing addListener fns to plugins --- src/builder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index 6d09ea4..d28426e 100644 --- a/src/builder.js +++ b/src/builder.js @@ -123,7 +123,7 @@ export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const listenerStore = createListenerStore(config); const addListener = set(['__addListener'], listenerStore.addListener); - const applyPlugins = reduce(applyPlugin(addListener, config), getEntityConfigs(c)); + const applyPlugins = reduce(applyPlugin(listenerStore.addListener, config), getEntityConfigs(c)); const createApi = compose(addListener, toApi, applyPlugins); return createApi([decorator(listenerStore.onChange), ...ps, dedup]); }; From a1b7220d373b53f910685f8177af229b71803e37 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 12:14:57 +0200 Subject: [PATCH 077/136] Add more subscriber specs and fix annotations --- src/plugins/subscriber/index.spec.js | 39 ++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index cc4ea39..45d99cf 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -17,7 +17,7 @@ const createConfig = () => { getUser.operation = 'READ'; getUser.byId = true; const getUsers = () => Promise.resolve(values(users)); - getUser.operation = 'READ'; + getUsers.operation = 'READ'; const updateUser = (nextUser) => { const { id } = nextUser; @@ -25,7 +25,7 @@ const createConfig = () => { users[id] = { ...user, ...nextUser }; return Promise.resolve(users[id]); }; - updateUser.operaton = 'UPDATE'; + updateUser.operation = 'UPDATE'; const removeUser = (id) => { delete users[id]; @@ -53,4 +53,39 @@ describe('subscriber', () => { expect(api.user.removeUser.createSubscriber).not.to.be; expect(api.user.updateUser.createSubscriber).not.to.be; }); + + describe('createSubscriber()', () => { + it('returns a subscriber shape', () => { + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + expect(subscriber.useArgs).to.be.a('function'); + expect(subscriber.destroy).to.be.a('function'); + expect(subscriber.subscribe).to.be.a('function'); + }); + }); + + describe('subscriber', () => { + describe('destroy', () => { + fit('removes all subscriptions', () => { + const spy1 = sinon.spy(); + const spy2 = sinon.spy(); + const api = build(createConfig(), [plugin()]); + + const subscriber = api.user.getUsers.createSubscriber(); + subscriber.subscribe(spy1); + subscriber.subscribe(spy2); + + return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => { + expect(spy1).to.have.been.calledOnce; + expect(spy2).to.have.been.calledOnce; + subscriber.destroy(); + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy1).to.have.been.calledOnce; + expect(spy2).to.have.been.calledOnce; + }); + }); + }); + }); + }); }); From 63f668deeb11e9d355593be86f2c44ce09b524b1 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 12:53:42 +0200 Subject: [PATCH 078/136] Adds specs for subsciptions --- src/plugins/subscriber/index.spec.js | 82 +++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 45d99cf..04c2524 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -6,6 +6,8 @@ import { build } from '../../builder'; import { toIdMap, values } from '../../fp'; import { subscriber as plugin } from '.'; +const delay = () => new Promise(res => setTimeout(() => res(), 1)); + const createConfig = () => { const peter = { id: 'peter', name: 'peter' }; const gernot = { id: 'gernot', name: 'gernot' }; @@ -41,7 +43,7 @@ const createConfig = () => { }; -describe('subscriber', () => { +describe('subscriber plugin', () => { it('patches fns so that a subscriber can be created on READ operations', () => { const api = build(createConfig(), [plugin()]); expect(api.user.getUsers.createSubscriber).to.be.a('function'); @@ -61,12 +63,13 @@ describe('subscriber', () => { expect(subscriber.useArgs).to.be.a('function'); expect(subscriber.destroy).to.be.a('function'); expect(subscriber.subscribe).to.be.a('function'); + expect(subscriber.alive).to.be.true; }); }); describe('subscriber', () => { describe('destroy', () => { - fit('removes all subscriptions', () => { + it('removes all subscriptions', () => { const spy1 = sinon.spy(); const spy2 = sinon.spy(); const api = build(createConfig(), [plugin()]); @@ -75,14 +78,79 @@ describe('subscriber', () => { subscriber.subscribe(spy1); subscriber.subscribe(spy2); - return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => { + return delay(() => { expect(spy1).to.have.been.calledOnce; expect(spy2).to.have.been.calledOnce; - subscriber.destroy(); - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy1).to.have.been.calledOnce; - expect(spy2).to.have.been.calledOnce; + return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => { + expect(spy1).to.have.been.calledTwice; + expect(spy2).to.have.been.calledTwice; + + subscriber.destroy(); + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy1).to.have.been.calledTwice; + expect(spy2).to.have.been.calledTwice; + }); + }); + }); + }); + + it('marks a subscriber as destroyed', () => { + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + expect(subscriber.alive).to.be.true; + + subscriber.destroy(); + expect(subscriber.alive).to.be.false; + }); + }); + }); + + describe('subscribe', () => { + it('immediately invokes for the first time', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.subscribe(spy); + + return delay(() => { + expect(spy).to.have.been.calledOnce; + }); + }); + + it('returns an unsuscribe function', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + const unsubscribe = subscriber.subscribe(spy); + + return delay(() => { + expect(spy).to.have.been.calledOnce; + unsubscribe(); + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + it('calls the callback again when a relevant change happens', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.subscribe(spy); + + return delay(() => { + expect(spy).to.have.been.calledOnce; + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => { + expect(spy).to.have.been.calledThrice; }); }); }); From 52c11a784fc62c8f06552560f61e44fbf35368ee Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 13:06:20 +0200 Subject: [PATCH 079/136] More spec fixes --- src/plugins/subscriber/index.spec.js | 88 +++++++++++++++++----------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 04c2524..ad4d740 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -6,7 +6,7 @@ import { build } from '../../builder'; import { toIdMap, values } from '../../fp'; import { subscriber as plugin } from '.'; -const delay = () => new Promise(res => setTimeout(() => res(), 1)); +const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); const createConfig = () => { const peter = { id: 'peter', name: 'peter' }; @@ -78,7 +78,7 @@ describe('subscriber plugin', () => { subscriber.subscribe(spy1); subscriber.subscribe(spy2); - return delay(() => { + return delay().then(() => { expect(spy1).to.have.been.calledOnce; expect(spy2).to.have.been.calledOnce; @@ -105,55 +105,77 @@ describe('subscriber plugin', () => { expect(subscriber.alive).to.be.false; }); }); - }); - describe('subscribe', () => { - it('immediately invokes for the first time', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + describe('subscribe', () => { + it('immediately invokes for the first time', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); - subscriber.subscribe(spy); + subscriber.subscribe(spy); - return delay(() => { - expect(spy).to.have.been.calledOnce; + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + }); }); - }); - it('returns an unsuscribe function', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + it('returns an unsuscribe function', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + const unsubscribe = subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + unsubscribe(); + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledOnce; + }); + }); + }); - const unsubscribe = subscriber.subscribe(spy); + it('calls the callback again when a relevant change happens', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); - return delay(() => { - expect(spy).to.have.been.calledOnce; - unsubscribe(); + subscriber.subscribe(spy); - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + return delay().then(() => { expect(spy).to.have.been.calledOnce; + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => { + expect(spy).to.have.been.calledThrice; + }); + }); }); }); - }); - it('calls the callback again when a relevant change happens', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + it('invokes the callback with no arguments by default', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); - subscriber.subscribe(spy); + subscriber.subscribe(spy); - return delay(() => { - expect(spy).to.have.been.calledOnce; + return delay().then(() => { + expect(spy).to.have.been.calledOnce; - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => { - expect(spy).to.have.been.calledThrice; + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; }); }); }); }); + + describe('useArgs', () => { + it('', () => { + + }); + }); }); }); From da9ecb08ed98a022133cdf32a5c40e02f6768344 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 13:06:36 +0200 Subject: [PATCH 080/136] First implementation attempt of subscriber --- src/plugins/subscriber/index.js | 53 +++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 src/plugins/subscriber/index.js diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js new file mode 100644 index 0000000..933ee18 --- /dev/null +++ b/src/plugins/subscriber/index.js @@ -0,0 +1,53 @@ +import { map_, removeElement } from '../../fp'; + +// eslint-disable-next-line no-unused-vars +const isRelevantChange = (entity, fn, change) => true; + +const createSubscriberFactory = (state, entity, fn) => () => { + let args = []; + let subscriptions = []; + + const changeListener = (change) => { + if (!subscriptions.length || !isRelevantChange(entity, fn, change)) { + return; + } + + fn(args).then((res) => map_((subscription) => subscription(res), subscriptions)); + }; + + const subscriber = { + destroy: () => { + subscriber.alive = false; + state.changeListeners = removeElement(changeListener, state.changeListeners); + subscriptions = []; + }, + useArgs: (...nextArgs) => { + args = nextArgs; + return subscriber; + }, + subscribe: (cb) => { + subscriptions.push(cb); + fn(args); // invoke fn, but not the cb. this will happen through the change listener + return () => { subscriptions = removeElement(cb, subscriptions); }; + }, + alive: true + }; + + state.changeListeners.push(changeListener); + return subscriber; +}; + +export const subscriber = () => ({ addListener }) => { + const state = { + changeListeners: [] + }; + + addListener((change) => map_((c) => c(change), state.changeListeners)); + + return ({ entity, fn }) => { + if (fn.operation !== 'READ') { return fn; } + fn.createSubscriber = createSubscriberFactory(state, entity, fn); + return fn; + }; +}; + From 7d626f1e184451d93805b6e12b9515ac30ab916f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 13:31:49 +0200 Subject: [PATCH 081/136] Add specs for withArgs --- src/plugins/subscriber/index.js | 4 ++-- src/plugins/subscriber/index.spec.js | 34 ++++++++++++++++++++++++---- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index 933ee18..8434b75 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -12,7 +12,7 @@ const createSubscriberFactory = (state, entity, fn) => () => { return; } - fn(args).then((res) => map_((subscription) => subscription(res), subscriptions)); + fn(...args).then((res) => map_((subscription) => subscription(res), subscriptions)); }; const subscriber = { @@ -21,7 +21,7 @@ const createSubscriberFactory = (state, entity, fn) => () => { state.changeListeners = removeElement(changeListener, state.changeListeners); subscriptions = []; }, - useArgs: (...nextArgs) => { + withArgs: (...nextArgs) => { args = nextArgs; return subscriber; }, diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index ad4d740..a593c54 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -60,7 +60,7 @@ describe('subscriber plugin', () => { it('returns a subscriber shape', () => { const api = build(createConfig(), [plugin()]); const subscriber = api.user.getUsers.createSubscriber(); - expect(subscriber.useArgs).to.be.a('function'); + expect(subscriber.withArgs).to.be.a('function'); expect(subscriber.destroy).to.be.a('function'); expect(subscriber.subscribe).to.be.a('function'); expect(subscriber.alive).to.be.true; @@ -69,7 +69,7 @@ describe('subscriber plugin', () => { describe('subscriber', () => { describe('destroy', () => { - it('removes all subscriptions', () => { + xit('removes all subscriptions', () => { const spy1 = sinon.spy(); const spy2 = sinon.spy(); const api = build(createConfig(), [plugin()]); @@ -172,9 +172,35 @@ describe('subscriber plugin', () => { }); }); - describe('useArgs', () => { - it('', () => { + describe('withArgs', () => { + it('returns the subscriber itself', () => { + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + const nextSubscriber = subscriber.withArgs(1, 2, 3); + expect(nextSubscriber).to.equal(subscriber); + }); + + it('allows to define args, which are used when making the api call', () => { + const config = createConfig(); + const stub = sinon.stub(); + stub.returns(Promise.resolve([])); + stub.operation = 'READ'; + config.user.api.getUsers = stub; + const api = build(config, [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.withArgs(1, 2, 3).subscribe(() => {}); + return delay().then(() => { + expect(stub).to.have.been.calledWith([1, 2, 3]); + + subscriber.withArgs('x'); + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(stub.args[1]).to.deep.equal(['x']); + }); + }); }); }); }); From 0323f0bbe8e9a5ea2b838000cc220775651c081e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 14:00:56 +0200 Subject: [PATCH 082/136] Make specs more robust and fix code --- src/plugins/subscriber/index.js | 8 ++++---- src/plugins/subscriber/index.spec.js | 15 +++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index 8434b75..579c275 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -4,7 +4,7 @@ import { map_, removeElement } from '../../fp'; const isRelevantChange = (entity, fn, change) => true; const createSubscriberFactory = (state, entity, fn) => () => { - let args = []; + let cachedArgs = []; let subscriptions = []; const changeListener = (change) => { @@ -12,7 +12,7 @@ const createSubscriberFactory = (state, entity, fn) => () => { return; } - fn(...args).then((res) => map_((subscription) => subscription(res), subscriptions)); + fn(...cachedArgs).then((res) => map_((subscription) => subscription(res), subscriptions)); }; const subscriber = { @@ -22,12 +22,12 @@ const createSubscriberFactory = (state, entity, fn) => () => { subscriptions = []; }, withArgs: (...nextArgs) => { - args = nextArgs; + cachedArgs = nextArgs; return subscriber; }, subscribe: (cb) => { subscriptions.push(cb); - fn(args); // invoke fn, but not the cb. this will happen through the change listener + fn(...cachedArgs); // invoke fn, but not the cb. this will happen through the change listener return () => { subscriptions = removeElement(cb, subscriptions); }; }, alive: true diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index a593c54..f283419 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -69,7 +69,7 @@ describe('subscriber plugin', () => { describe('subscriber', () => { describe('destroy', () => { - xit('removes all subscriptions', () => { + it('removes all subscriptions', () => { const spy1 = sinon.spy(); const spy2 = sinon.spy(); const api = build(createConfig(), [plugin()]); @@ -79,18 +79,17 @@ describe('subscriber plugin', () => { subscriber.subscribe(spy2); return delay().then(() => { - expect(spy1).to.have.been.calledOnce; - expect(spy2).to.have.been.calledOnce; + const initialCallCount = spy1.callCount; return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => { - expect(spy1).to.have.been.calledTwice; - expect(spy2).to.have.been.calledTwice; + expect(spy1.callCount).to.equal(initialCallCount + 1); + expect(spy2.callCount).to.equal(initialCallCount + 1); subscriber.destroy(); return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy1).to.have.been.calledTwice; - expect(spy2).to.have.been.calledTwice; + expect(spy1.callCount).to.equal(initialCallCount + 1); + expect(spy2.callCount).to.equal(initialCallCount + 1); }); }); }); @@ -193,7 +192,7 @@ describe('subscriber plugin', () => { subscriber.withArgs(1, 2, 3).subscribe(() => {}); return delay().then(() => { - expect(stub).to.have.been.calledWith([1, 2, 3]); + expect(stub).to.have.been.calledWith(1, 2, 3); subscriber.withArgs('x'); From 1f57d2904cefeed6f36bd01aba1baef621f0b42b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 14:06:10 +0200 Subject: [PATCH 083/136] Document a current limitation --- src/plugins/subscriber/index.spec.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index f283419..0c12f36 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -169,6 +169,19 @@ describe('subscriber plugin', () => { }); }); }); + + // eslint-disable-next-line max-len + it('several subscriptions lead to several initial invocations - this is a limitation of Ladda\'s listener interface at the moment and needs to be corrected there. This just documents the fact for now', () => { + const spies = [sinon.spy(), sinon.spy(), sinon.spy()]; + const api = build(createConfig(), [plugin()]); + + const subscriber = api.user.getUsers.createSubscriber(); + spies.forEach((spy) => subscriber.subscribe(spy)); + + return delay().then(() => { + spies.forEach((spy) => expect(spy.callCount).to.equal(spies.length)); + }); + }); }); describe('withArgs', () => { From c1c19a70399c7bcbf21086c860b1d8aa9c4e325b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 14:12:36 +0200 Subject: [PATCH 084/136] Add a todo about checking to see whether a change is relevant --- src/plugins/subscriber/index.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index 579c275..f9dd45c 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -1,14 +1,28 @@ import { map_, removeElement } from '../../fp'; -// eslint-disable-next-line no-unused-vars -const isRelevantChange = (entity, fn, change) => true; +const isChangeOfSameEntity = (entity, change) => entity.name === change.entity; -const createSubscriberFactory = (state, entity, fn) => () => { +const isRelevantChange = (entityConifgs, entity, fn, change) => { + // TODO + // take several other reasons into account + // - views! + // - find unobtrusive way to play nice with denormalizer + // + // This could potentially also be optimized. E.g., don't notify when you + // know that you're dealing with an item that is not relevant for you. + // Could be found out by looking at byId and byIds annotations. + // It's not a big deal if this is called again though for now - might + // not be worth the additional complexity + // + return isChangeOfSameEntity(entity, change); +}; + +const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { let cachedArgs = []; let subscriptions = []; const changeListener = (change) => { - if (!subscriptions.length || !isRelevantChange(entity, fn, change)) { + if (!subscriptions.length || !isRelevantChange(entityConfigs, entity, fn, change)) { return; } @@ -37,7 +51,7 @@ const createSubscriberFactory = (state, entity, fn) => () => { return subscriber; }; -export const subscriber = () => ({ addListener }) => { +export const subscriber = () => ({ addListener, entityConfigs }) => { const state = { changeListeners: [] }; @@ -46,7 +60,7 @@ export const subscriber = () => ({ addListener }) => { return ({ entity, fn }) => { if (fn.operation !== 'READ') { return fn; } - fn.createSubscriber = createSubscriberFactory(state, entity, fn); + fn.createSubscriber = createSubscriberFactory(state, entityConfigs, entity, fn); return fn; }; }; From f17a1f1f35d69126a298c4c7a6a7386cbb19659c Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 16:54:03 +0200 Subject: [PATCH 085/136] Expose plugins to outside world (only temporarily!) --- src/release.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/release.js b/src/release.js index 854331c..df09895 100644 --- a/src/release.js +++ b/src/release.js @@ -1,5 +1,11 @@ import { build } from './builder'; +import { subscriber } from './plugins/subscriber'; +import { denormalizer } from './plugins/denormalizer'; module.exports = { - build + build, + plugins: { + subscriber, + denormalizer + } }; From a2f314978f536c41bc6c5c30644b75f68a37a5a4 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 18:25:51 +0200 Subject: [PATCH 086/136] Handle errorCb in subscriber - clean up initial invocation in the process --- src/plugins/subscriber/index.js | 26 ++++++++++++++++++++------ src/plugins/subscriber/index.spec.js | 24 ++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index f9dd45c..73a1623 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -1,4 +1,4 @@ -import { map_, removeElement } from '../../fp'; +import { map_, noop, removeElement } from '../../fp'; const isChangeOfSameEntity = (entity, change) => entity.name === change.entity; @@ -26,7 +26,10 @@ const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { return; } - fn(...cachedArgs).then((res) => map_((subscription) => subscription(res), subscriptions)); + fn(...cachedArgs).then( + (res) => map_((subscription) => subscription.successCb(res), subscriptions), + (err) => map_((subscription) => subscription.errorCb(err), subscriptions) + ); }; const subscriber = { @@ -39,10 +42,21 @@ const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { cachedArgs = nextArgs; return subscriber; }, - subscribe: (cb) => { - subscriptions.push(cb); - fn(...cachedArgs); // invoke fn, but not the cb. this will happen through the change listener - return () => { subscriptions = removeElement(cb, subscriptions); }; + subscribe: (successCb, errorCb = noop) => { + const subscription = { successCb, errorCb }; + // add ourselves to the subscription list after the first initial call, + // so that we don't consume a change we triggered ourselves. + fn(...cachedArgs).then( + (res) => { + successCb(res); + subscriptions.push(subscription); + }, + (err) => { + errorCb(err); + subscriptions.push(subscription); + } + ); + return () => { subscriptions = removeElement(subscription, subscriptions); }; }, alive: true }; diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 0c12f36..f751881 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -170,8 +170,28 @@ describe('subscriber plugin', () => { }); }); + it('takes an optional second callback invoked on error', () => { + const spy = sinon.spy(); + const errSpy = sinon.spy(); + const error = { err: 'x' }; + const config = createConfig(); + config.user.api.getUsers = () => Promise.reject(error); + config.user.api.getUsers.operation = 'READ'; + + const api = build(config, [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.subscribe(spy, errSpy); + + return delay().then(() => { + expect(spy).not.to.have.been.called; + expect(errSpy).to.have.been.calledOnce; + expect(errSpy).to.have.been.calledWith(error); + }); + }); + // eslint-disable-next-line max-len - it('several subscriptions lead to several initial invocations - this is a limitation of Ladda\'s listener interface at the moment and needs to be corrected there. This just documents the fact for now', () => { + it('several parallel subscriptions guarantee to call each subscription only once initially', () => { const spies = [sinon.spy(), sinon.spy(), sinon.spy()]; const api = build(createConfig(), [plugin()]); @@ -179,7 +199,7 @@ describe('subscriber plugin', () => { spies.forEach((spy) => subscriber.subscribe(spy)); return delay().then(() => { - spies.forEach((spy) => expect(spy.callCount).to.equal(spies.length)); + spies.forEach((spy) => expect(spy.callCount).to.equal(1)); }); }); }); From 5c1e34bf5a66088ad3805c8e4eaf2060f5c9ea4c Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 19:22:01 +0200 Subject: [PATCH 087/136] Remove the withArgs indirection --- src/plugins/subscriber/index.js | 11 ++----- src/plugins/subscriber/index.spec.js | 47 +++++++++------------------- 2 files changed, 18 insertions(+), 40 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index 73a1623..7035731 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -17,8 +17,7 @@ const isRelevantChange = (entityConifgs, entity, fn, change) => { return isChangeOfSameEntity(entity, change); }; -const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { - let cachedArgs = []; +const createSubscriberFactory = (state, entityConfigs, entity, fn) => (...args) => { let subscriptions = []; const changeListener = (change) => { @@ -26,7 +25,7 @@ const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { return; } - fn(...cachedArgs).then( + fn(...args).then( (res) => map_((subscription) => subscription.successCb(res), subscriptions), (err) => map_((subscription) => subscription.errorCb(err), subscriptions) ); @@ -38,15 +37,11 @@ const createSubscriberFactory = (state, entityConfigs, entity, fn) => () => { state.changeListeners = removeElement(changeListener, state.changeListeners); subscriptions = []; }, - withArgs: (...nextArgs) => { - cachedArgs = nextArgs; - return subscriber; - }, subscribe: (successCb, errorCb = noop) => { const subscription = { successCb, errorCb }; // add ourselves to the subscription list after the first initial call, // so that we don't consume a change we triggered ourselves. - fn(...cachedArgs).then( + fn(...args).then( (res) => { successCb(res); subscriptions.push(subscription); diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index f751881..378b344 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -60,7 +60,6 @@ describe('subscriber plugin', () => { it('returns a subscriber shape', () => { const api = build(createConfig(), [plugin()]); const subscriber = api.user.getUsers.createSubscriber(); - expect(subscriber.withArgs).to.be.a('function'); expect(subscriber.destroy).to.be.a('function'); expect(subscriber.subscribe).to.be.a('function'); expect(subscriber.alive).to.be.true; @@ -154,22 +153,6 @@ describe('subscriber plugin', () => { }); }); - it('invokes the callback with no arguments by default', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - - subscriber.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - }); - }); - }); - it('takes an optional second callback invoked on error', () => { const spy = sinon.spy(); const errSpy = sinon.spy(); @@ -202,36 +185,36 @@ describe('subscriber plugin', () => { spies.forEach((spy) => expect(spy.callCount).to.equal(1)); }); }); - }); - describe('withArgs', () => { - it('returns the subscriber itself', () => { + it('invokes the callback with no arguments by default', () => { + const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); const subscriber = api.user.getUsers.createSubscriber(); - const nextSubscriber = subscriber.withArgs(1, 2, 3); - expect(nextSubscriber).to.equal(subscriber); + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); }); - it('allows to define args, which are used when making the api call', () => { + it('takes the arguments of the create call and passes them to the api call', () => { const config = createConfig(); const stub = sinon.stub(); + const spy = sinon.spy(); stub.returns(Promise.resolve([])); stub.operation = 'READ'; config.user.api.getUsers = stub; const api = build(config, [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - - subscriber.withArgs(1, 2, 3).subscribe(() => {}); + const subscriber = api.user.getUsers.createSubscriber(1, 2, 3); + subscriber.subscribe(spy); return delay().then(() => { expect(stub).to.have.been.calledWith(1, 2, 3); - - subscriber.withArgs('x'); - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(stub.args[1]).to.deep.equal(['x']); - }); }); }); }); From 1c9e5b3200ddd17e2cbd1f2f4a7240d036ea5e4a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 19:28:56 +0200 Subject: [PATCH 088/136] Add todo about views --- src/entity-store.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/entity-store.js b/src/entity-store.js index e67edc9..622199e 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -53,6 +53,7 @@ const isView = e => !!e.viewOf; // EntityStore -> Hook const getHook = (es) => es[2]; +// TODO All hook code needs to be able to deal with views also! // EntityStore -> Type -> [Entity] -> () const triggerHook = curry((es, e, type, xs) => getHook(es)({ type, From ddf5723d5bd06f4649eb4040956c1d090a4b5d06 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 9 Apr 2017 20:32:49 +0200 Subject: [PATCH 089/136] Check in webpack for proper builds --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9c7a2f3..c58093f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "mocha": "^2.5.3", "nyc": "^10.1.2", "sinon": "^1.17.7", - "sinon-chai": "^2.8.0" + "sinon-chai": "^2.8.0", + "webpack": "^1.14.0" }, "scripts": { "docs:prepare": "gitbook install", From c90749994b65d6f1e7b588746514cafbee5c9e4a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 16 Apr 2017 17:31:29 +0200 Subject: [PATCH 090/136] Move fp to own folder (easier to extract later if need be) --- src/{fp.js => fp/index.js} | 0 src/{fp.spec.js => fp/index.spec.js} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{fp.js => fp/index.js} (100%) rename src/{fp.spec.js => fp/index.spec.js} (99%) diff --git a/src/fp.js b/src/fp/index.js similarity index 100% rename from src/fp.js rename to src/fp/index.js diff --git a/src/fp.spec.js b/src/fp/index.spec.js similarity index 99% rename from src/fp.spec.js rename to src/fp/index.spec.js index b356a3b..6629b24 100644 --- a/src/fp.spec.js +++ b/src/fp/index.spec.js @@ -7,7 +7,7 @@ import {debug, identity, curry, passThrough, reduce, compose, prop, zip, flip, toPairs, fromPairs, mapObject, mapValues, toObject, filter, clone, filterObject, copyFunction, get, set, concat, flatten, uniq, toIdMap, - removeAtIndex, removeElement} from './fp'; + removeAtIndex, removeElement} from '.'; describe('fp', () => { describe('debug', () => { From 8c9de8ee6c18161e0da1ef3a909e2129b75cb410 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 07:29:23 +0200 Subject: [PATCH 091/136] Setup subscriber specs for views and invalidations --- src/plugins/subscriber/index.spec.js | 134 ++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 15 deletions(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 378b344..1c26079 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -3,41 +3,63 @@ import sinon from 'sinon'; import { build } from '../../builder'; -import { toIdMap, values } from '../../fp'; +import { compose, map, toIdMap, values } from '../../fp'; import { subscriber as plugin } from '.'; const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); +const toMiniUser = ({ id, name }) => ({ id, name }); -const createConfig = () => { - const peter = { id: 'peter', name: 'peter' }; - const gernot = { id: 'gernot', name: 'gernot' }; - const robin = { id: 'robin', name: 'robin' }; - - const users = toIdMap([peter, gernot, robin]); - - const getUser = (id) => Promise.resolve(users[id]); +const createUserApi = (container) => { + const getUser = (id) => Promise.resolve(container[id]); getUser.operation = 'READ'; getUser.byId = true; - const getUsers = () => Promise.resolve(values(users)); + const getUsers = () => Promise.resolve(values(container)); getUsers.operation = 'READ'; const updateUser = (nextUser) => { const { id } = nextUser; - const user = users[id]; - users[id] = { ...user, ...nextUser }; - return Promise.resolve(users[id]); + const user = container[id]; + container[id] = { ...user, ...nextUser }; + return Promise.resolve(container[id]); }; updateUser.operation = 'UPDATE'; const removeUser = (id) => { - delete users[id]; + delete container[id]; Promise.resolve(); }; removeUser.operation = 'DELETE'; + return { getUser, getUsers, updateUser, removeUser }; +}; + +const createConfig = () => { + const peter = { id: 'peter', name: 'peter', location: 'gothenburg' }; + const gernot = { id: 'gernot', name: 'gernot', location: 'graz' }; + const robin = { id: 'robin', name: 'robin', location: 'berlin' }; + + const list = [peter, gernot, robin]; + const users = toIdMap(list); + const miniUsers = compose(toIdMap, map(toMiniUser))(list); + + const getActivities = () => Promise.resolve([]); + getActivities.operation = 'READ'; + return { user: { - api: { getUser, getUsers, updateUser, removeUser } + api: createUserApi(users) + }, + mediumUser: { + api: createUserApi(users), + viewOf: 'user', + invalidates: ['activity'] + }, + miniUser: { + api: createUserApi(miniUsers), + viewOf: 'miniUser' + }, + activity: { + api: { getActivities } } }; }; @@ -217,6 +239,88 @@ describe('subscriber plugin', () => { expect(stub).to.have.been.calledWith(1, 2, 3); }); }); + + describe('with views', () => { + it('notices changes to a child view', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); + }); + + it('notices changes to a parent view', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.miniUser.getUsers.createSubscriber(); + + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); + }); + + it('notices direct invalidations', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.activity.getActivities.createSubscriber(); + + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); + }); + + it('notices indirect invalidations (through a parent)', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.activity.getActivities.createSubscriber(); + + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); + }); + + it('notices indirect invalidations (through a child)', () => { + const spy = sinon.spy(); + const api = build(createConfig(), [plugin()]); + const subscriber = api.activity.getActivities.createSubscriber(); + + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + expect(spy).to.have.been.calledTwice; + }); + }); + }); + }); }); }); }); From f50a9c5a02b8c7cfaabc57cf02c5f2c53d83163c Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 08:48:01 +0200 Subject: [PATCH 092/136] Add subscriber helper to parse relationships between entities --- src/plugins/subscriber/helper.js | 47 ++++++++++++++++++++++++ src/plugins/subscriber/helper.spec.js | 52 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 src/plugins/subscriber/helper.js create mode 100644 src/plugins/subscriber/helper.spec.js diff --git a/src/plugins/subscriber/helper.js b/src/plugins/subscriber/helper.js new file mode 100644 index 0000000..7dc2f48 --- /dev/null +++ b/src/plugins/subscriber/helper.js @@ -0,0 +1,47 @@ + +const getOrCreateContainer = (type, relationships) => { + let container = relationships[type]; + if (!container) { + // eslint-disable-next-line no-multi-assign + container = relationships[type] = { views: [], parents: [], invalidatedBy: [] }; + } + return container; +}; + +const getParentViews = (configs, type, res = []) => { + const config = configs[type]; + if (config.viewOf) { + const parent = config.viewOf; + return getParentViews(configs, parent, [...res, parent]); + } + return res; +}; + +const analyzeViews = (configs, type, rels) => { + const container = getOrCreateContainer(type, rels); + const parents = getParentViews(configs, type); + container.parents = [...container.parents, ...parents]; + parents.forEach((parent) => { + getOrCreateContainer(parent, rels).views.push(type); + }); + return rels; +}; + +const analyzeInvalidations = (configs, rels) => { + Object.keys(rels).forEach((type) => { + const invalidates = configs[type].invalidates || []; + const rel = rels[type]; + invalidates.forEach((invalidatedType) => { + rels[invalidatedType].invalidatedBy = [...rel.parents, type, ...rel.views]; + }); + }); + return rels; +}; + +export const analyzeEntityRelationships = (entityConfigs) => { + const relationships = Object.keys(entityConfigs).reduce((rels, type) => { + return analyzeViews(entityConfigs, type, rels); + }, {}); + return analyzeInvalidations(entityConfigs, relationships); +}; + diff --git a/src/plugins/subscriber/helper.spec.js b/src/plugins/subscriber/helper.spec.js new file mode 100644 index 0000000..8feb69b --- /dev/null +++ b/src/plugins/subscriber/helper.spec.js @@ -0,0 +1,52 @@ +import { analyzeEntityRelationships } from './helper'; + +describe('subscriber helper', () => { + describe('analyzeEntityRelationships', () => { + it('returns an object to determine parents, views an invalidations per entity', () => { + const config = { + user: { + api: {} + }, + mediumUser: { + api: {}, + viewOf: 'user', + invalidates: ['activity'] + }, + miniUser: { + api: {}, + viewOf: 'mediumUser' + }, + activity: { + api: {} + } + }; + + const expected = { + user: { + views: ['mediumUser', 'miniUser'], + parents: [], + invalidatedBy: [] + }, + mediumUser: { + views: ['miniUser'], + parents: ['user'], + invalidatedBy: [] + }, + miniUser: { + views: [], + parents: ['mediumUser', 'user'], + invalidatedBy: [] + }, + activity: { + views: [], + parents: [], + invalidatedBy: ['user', 'mediumUser', 'miniUser'] + } + }; + + const actual = analyzeEntityRelationships(config); + + expect(actual).to.deep.equal(expected); + }); + }); +}); From 7045d31d6b97431a26b1321769937b0a7c37f3ed Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 08:48:23 +0200 Subject: [PATCH 093/136] Fix config in subscriber spec --- src/plugins/subscriber/index.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 1c26079..e06d941 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -56,7 +56,7 @@ const createConfig = () => { }, miniUser: { api: createUserApi(miniUsers), - viewOf: 'miniUser' + viewOf: 'mediumUser' }, activity: { api: { getActivities } From 4b180535406094734bc6ac25df6d1bc48261a0b8 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 08:48:41 +0200 Subject: [PATCH 094/136] Support views and invalidations in subscriber plugin --- src/plugins/subscriber/index.js | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/plugins/subscriber/index.js b/src/plugins/subscriber/index.js index 7035731..efb13c6 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/subscriber/index.js @@ -1,27 +1,41 @@ import { map_, noop, removeElement } from '../../fp'; +import { analyzeEntityRelationships } from './helper'; const isChangeOfSameEntity = (entity, change) => entity.name === change.entity; +const isChangeOfView = (rel, change) => rel.views.indexOf(change.entity) !== -1; +const isChangeOfParent = (rel, change) => rel.parents.indexOf(change.entity) !== -1; +const isInvalidatedByChange = (rel, change) => rel.invalidatedBy.indexOf(change.entity) !== -1; -const isRelevantChange = (entityConifgs, entity, fn, change) => { +const isRelevantChange = (relationships, entity, fn, change) => { // TODO - // take several other reasons into account - // - views! // - find unobtrusive way to play nice with denormalizer // // This could potentially also be optimized. E.g., don't notify when you // know that you're dealing with an item that is not relevant for you. // Could be found out by looking at byId and byIds annotations. // It's not a big deal if this is called again though for now - might - // not be worth the additional complexity + // not be worth the additional complexity. Checking might also take + // just as long as calling the cache again anyway. + // We might however trigger an unnecessary follow up action by consumer + // code (such as re-rendering in an UI), which is not the nicest behavior // - return isChangeOfSameEntity(entity, change); + // In general we are very aggressive here and rather have a false positive + // before we miss out on a change. + // Therefore all related entity changes, also considering invalidations, + // re-trigger here. + // + const rel = relationships[entity.name]; + return isChangeOfSameEntity(entity, change) || + isChangeOfView(rel, change) || + isChangeOfParent(rel, change) || + isInvalidatedByChange(rel, change); }; -const createSubscriberFactory = (state, entityConfigs, entity, fn) => (...args) => { +const createSubscriberFactory = (state, relationships, entityConfigs, entity, fn) => (...args) => { let subscriptions = []; const changeListener = (change) => { - if (!subscriptions.length || !isRelevantChange(entityConfigs, entity, fn, change)) { + if (!subscriptions.length || !isRelevantChange(relationships, entity, fn, change)) { return; } @@ -65,11 +79,13 @@ export const subscriber = () => ({ addListener, entityConfigs }) => { changeListeners: [] }; + const relationships = analyzeEntityRelationships(entityConfigs); + addListener((change) => map_((c) => c(change), state.changeListeners)); return ({ entity, fn }) => { if (fn.operation !== 'READ') { return fn; } - fn.createSubscriber = createSubscriberFactory(state, entityConfigs, entity, fn); + fn.createSubscriber = createSubscriberFactory(state, relationships, entityConfigs, entity, fn); return fn; }; }; From e23b9c3d6ee246aad28b3f8945ee70c6a66ff82b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 16:13:59 +0200 Subject: [PATCH 095/136] Do not trigger subscription based on invalidation of views --- src/plugins/subscriber/helper.js | 3 +-- src/plugins/subscriber/helper.spec.js | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/subscriber/helper.js b/src/plugins/subscriber/helper.js index 7dc2f48..a052936 100644 --- a/src/plugins/subscriber/helper.js +++ b/src/plugins/subscriber/helper.js @@ -30,9 +30,8 @@ const analyzeViews = (configs, type, rels) => { const analyzeInvalidations = (configs, rels) => { Object.keys(rels).forEach((type) => { const invalidates = configs[type].invalidates || []; - const rel = rels[type]; invalidates.forEach((invalidatedType) => { - rels[invalidatedType].invalidatedBy = [...rel.parents, type, ...rel.views]; + rels[invalidatedType].invalidatedBy.push(type); }); }); return rels; diff --git a/src/plugins/subscriber/helper.spec.js b/src/plugins/subscriber/helper.spec.js index 8feb69b..d3002c2 100644 --- a/src/plugins/subscriber/helper.spec.js +++ b/src/plugins/subscriber/helper.spec.js @@ -14,7 +14,8 @@ describe('subscriber helper', () => { }, miniUser: { api: {}, - viewOf: 'mediumUser' + viewOf: 'mediumUser', + invalidates: ['activity'] }, activity: { api: {} @@ -40,7 +41,7 @@ describe('subscriber helper', () => { activity: { views: [], parents: [], - invalidatedBy: ['user', 'mediumUser', 'miniUser'] + invalidatedBy: ['mediumUser', 'miniUser'] } }; From 9a522ba48b3d23f8c7d55336092ccbb901eb666f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 16:17:30 +0200 Subject: [PATCH 096/136] Let ChangeObject use real entityName, instead of taking views into account --- src/entity-store.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/entity-store.js b/src/entity-store.js index 622199e..b8fbd68 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -53,11 +53,10 @@ const isView = e => !!e.viewOf; // EntityStore -> Hook const getHook = (es) => es[2]; -// TODO All hook code needs to be able to deal with views also! // EntityStore -> Type -> [Entity] -> () const triggerHook = curry((es, e, type, xs) => getHook(es)({ type, - entity: getEntityType(e), + entity: e.name, // real name, not getEntityType, which takes views into account! entities: removeId(xs) })); From cf453b9a6e21a96486ec4a51a9d6a81d3bdf951a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 16:32:08 +0200 Subject: [PATCH 097/136] Do NOT update cache optimistically - trigger invalidations before we do other operations --- src/decorator/create.js | 4 ++-- src/decorator/delete.js | 4 ++-- src/decorator/update.js | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/decorator/create.js b/src/decorator/create.js index 672d72c..186e53a 100644 --- a/src/decorator/create.js +++ b/src/decorator/create.js @@ -6,7 +6,7 @@ import {addId} from '../id-helper'; export function decorateCreate(c, es, qc, e, aFn) { return (...args) => { return aFn(...args) - .then(passThrough(compose(put(es, e), addId(c, aFn, args)))) - .then(passThrough(() => invalidate(qc, e, aFn))); + .then(passThrough(() => invalidate(qc, e, aFn))) + .then(passThrough(compose(put(es, e), addId(c, aFn, args)))); }; } diff --git a/src/decorator/delete.js b/src/decorator/delete.js index a9f95c5..61663df 100644 --- a/src/decorator/delete.js +++ b/src/decorator/delete.js @@ -5,8 +5,8 @@ import {serialize} from '../serializer'; export function decorateDelete(c, es, qc, e, aFn) { return (...args) => { - remove(es, e, serialize(args)); return aFn(...args) - .then(passThrough(() => invalidate(qc, e, aFn))); + .then(passThrough(() => invalidate(qc, e, aFn))) + .then(() => remove(es, e, serialize(args))); }; } diff --git a/src/decorator/update.js b/src/decorator/update.js index 475ae4a..e347add 100644 --- a/src/decorator/update.js +++ b/src/decorator/update.js @@ -5,8 +5,8 @@ import {addId} from '../id-helper'; export function decorateUpdate(c, es, qc, e, aFn) { return (eValue, ...args) => { - put(es, e, addId(c, undefined, undefined, eValue)); return aFn(eValue, ...args) - .then(passThrough(() => invalidate(qc, e, aFn))); + .then(passThrough(() => invalidate(qc, e, aFn))) + .then(passThrough(() => put(es, e, addId(c, undefined, undefined, eValue)))); }; } From 0535c15426c8eac8aeec083e4303cfa4adbb35b1 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 16:32:49 +0200 Subject: [PATCH 098/136] Make sure subscriptions handle invalidations correctly --- src/plugins/subscriber/index.spec.js | 105 ++++++++++++++++++++++++--- 1 file changed, 94 insertions(+), 11 deletions(-) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index e06d941..488cc12 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -24,13 +24,19 @@ const createUserApi = (container) => { }; updateUser.operation = 'UPDATE'; + const createUser = (user) => { + container[user.id] = user; + return Promise.resolve(user); + }; + createUser.operation = 'CREATE'; + const removeUser = (id) => { delete container[id]; - Promise.resolve(); + return Promise.resolve(); }; removeUser.operation = 'DELETE'; - return { getUser, getUsers, updateUser, removeUser }; + return { getUser, getUsers, createUser, updateUser, removeUser }; }; const createConfig = () => { @@ -284,14 +290,23 @@ describe('subscriber plugin', () => { expect(spy).to.have.been.calledOnce; return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; + return delay().then(() => { + expect(spy).to.have.been.calledThrice; + }); }); }); }); - it('notices indirect invalidations (through a parent)', () => { + it('calls api fns only AFTER they have been invalidated (on update)', () => { + const config = createConfig(); + const state = { + activities: [{ id: 'a' }] + }; + config.activity.api.getActivities = () => Promise.resolve(state.activities); + config.activity.api.getActivities.operation = 'READ'; + const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); + const api = build(config, [plugin()]); const subscriber = api.activity.getActivities.createSubscriber(); subscriber.subscribe(spy); @@ -299,15 +314,39 @@ describe('subscriber plugin', () => { return delay().then(() => { expect(spy).to.have.been.calledOnce; - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; + const firstArgs = spy.args[0]; + expect(firstArgs.length).to.equal(1); + + state.activities = [...state.activities, { id: 'b' }]; + + return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { + return delay().then(() => { + // TODO + // Ideally this should be called only twice: + // 1. The initial subscription + // 2. The call after the invalidation by updateUser + // + // The third call happens after 2. - as this call also creates + // an update event. The subscription is consuming its own event. + // Not ideal, but not trivial to fix. + expect(spy).to.have.been.calledThrice; + const secondArgs = spy.args[1]; + expect(secondArgs[0].length).to.equal(2); + }); }); }); }); - it('notices indirect invalidations (through a child)', () => { + it('calls api fns only AFTER they have been invalidated (on create)', () => { + const config = createConfig(); + const state = { + activities: [{ id: 'a' }] + }; + config.activity.api.getActivities = () => Promise.resolve(state.activities); + config.activity.api.getActivities.operation = 'READ'; + const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); + const api = build(config, [plugin()]); const subscriber = api.activity.getActivities.createSubscriber(); subscriber.subscribe(spy); @@ -315,8 +354,52 @@ describe('subscriber plugin', () => { return delay().then(() => { expect(spy).to.have.been.calledOnce; - return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; + const firstArgs = spy.args[0]; + expect(firstArgs.length).to.equal(1); + + state.activities = [...state.activities, { id: 'b' }]; + + return api.mediumUser.createUser({ id: 'timur', name: 'Timur' }).then(() => { + return delay().then(() => { + expect(spy).to.have.been.calledThrice; + const secondArgs = spy.args[1]; + expect(secondArgs[0].length).to.equal(2); + }); + }); + }); + }); + + it('calls api fns only AFTER they have been invalidated (on remove)', () => { + const config = createConfig(); + const state = { + activities: [{ id: 'a' }] + }; + config.activity.api.getActivities = () => Promise.resolve(state.activities); + config.activity.api.getActivities.operation = 'READ'; + + const spy = sinon.spy(); + const api = build(config, [plugin()]); + const subscriber = api.activity.getActivities.createSubscriber(); + + // fill the cache so that we can remove items later + api.user.getUsers().then(() => { + subscriber.subscribe(spy); + + return delay().then(() => { + expect(spy).to.have.been.calledOnce; + + const firstArgs = spy.args[0]; + expect(firstArgs.length).to.equal(1); + + state.activities = [...state.activities, { id: 'b' }]; + + return api.mediumUser.removeUser('peter').then(() => { + return delay().then(() => { + expect(spy).to.have.been.calledThrice; + const secondArgs = spy.args[1]; + expect(secondArgs[0].length).to.equal(2); + }); + }); }); }); }); From e8edb8e9e5e4d94a28070be9d4dc677f79dd3744 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 17:05:02 +0200 Subject: [PATCH 099/136] Improve test coverage for subscriber --- src/plugins/subscriber/index.spec.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/subscriber/index.spec.js index 488cc12..b35a69e 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/subscriber/index.spec.js @@ -201,6 +201,33 @@ describe('subscriber plugin', () => { }); }); + it('also invokes error callback in later stages of the subscription\'s lifecycle', () => { + const spy = sinon.spy(); + const errSpy = sinon.spy(); + const error = { err: 'x' }; + const config = createConfig(); + config.user.api.getUsers = () => Promise.reject(error); + config.user.api.getUsers.operation = 'READ'; + + const api = build(config, [plugin()]); + const subscriber = api.user.getUsers.createSubscriber(); + + subscriber.subscribe(spy, errSpy); + + return delay().then(() => { + expect(spy).not.to.have.been.called; + expect(errSpy).to.have.been.calledOnce; + expect(errSpy).to.have.been.calledWith(error); + + return api.user.createUser({ id: 'x', name: 'y' }).then(() => { + return delay().then(() => { + expect(spy).not.to.have.been.called; + expect(errSpy).to.have.been.calledTwice; + }); + }); + }); + }); + // eslint-disable-next-line max-len it('several parallel subscriptions guarantee to call each subscription only once initially', () => { const spies = [sinon.spy(), sinon.spy(), sinon.spy()]; From edc8d41abab117dd38ee0a8113fad12a76fabdd1 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 17 Apr 2017 17:13:20 +0200 Subject: [PATCH 100/136] Add more specs to document __addListener --- src/builder.spec.js | 49 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/builder.spec.js b/src/builder.spec.js index 3c39a21..8f4825e 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -4,7 +4,8 @@ import sinon from 'sinon'; import {build} from './builder'; import {curry} from './fp'; -const getUsers = () => Promise.resolve([{id: 1}, {id: 2}]); +const users = [{ id: 1 }, { id: 2 }]; +const getUsers = () => Promise.resolve(users); getUsers.operation = 'READ'; const deleteUser = () => Promise.resolve(); @@ -147,4 +148,50 @@ describe('builder', () => { .then(expectACall) .then(() => done()); }); + + it('exposes Ladda\'s listener/onChange interface', () => { + const api = build(config()); + expect(api.__addListener).to.be; + }); + + describe('__addListener', () => { + it('allows to add a listener, which gets notified on all cache changes', () => { + const api = build(config()); + const spy = sinon.spy(); + api.__addListener(spy); + + return api.user.getUsers().then(() => { + expect(spy).to.have.been.calledOnce; + const changeObject = spy.args[0][0]; + expect(changeObject.entity).to.equal('user'); + expect(changeObject.type).to.equal('UPDATE'); + expect(changeObject.entities).to.deep.equal(users); + }); + }); + + it('does not trigger when a pure cache hit is made', () => { + const api = build(config()); + const spy = sinon.spy(); + api.__addListener(spy); + + return api.user.getUsers().then(() => { + expect(spy).to.have.been.calledOnce; + + return api.user.getUsers().then(() => { + expect(spy).to.have.been.calledOnce; + }); + }); + }); + + it('returns a deregistration function to remove the listener', () => { + const api = build(config()); + const spy = sinon.spy(); + const deregister = api.__addListener(spy); + deregister(); + + return api.user.getUsers().then(() => { + expect(spy).not.to.have.been.called; + }); + }); + }); }); From 9970a2db61f6c6c51152217352fdc458ecd89396 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 18 Apr 2017 08:06:28 +0200 Subject: [PATCH 101/136] Rename subscriber to observable --- .../{subscriber => observable}/helper.js | 0 .../{subscriber => observable}/helper.spec.js | 0 .../{subscriber => observable}/index.js | 6 +- .../{subscriber => observable}/index.spec.js | 102 +++++++++--------- 4 files changed, 54 insertions(+), 54 deletions(-) rename src/plugins/{subscriber => observable}/helper.js (100%) rename src/plugins/{subscriber => observable}/helper.spec.js (100%) rename src/plugins/{subscriber => observable}/index.js (94%) rename src/plugins/{subscriber => observable}/index.spec.js (81%) diff --git a/src/plugins/subscriber/helper.js b/src/plugins/observable/helper.js similarity index 100% rename from src/plugins/subscriber/helper.js rename to src/plugins/observable/helper.js diff --git a/src/plugins/subscriber/helper.spec.js b/src/plugins/observable/helper.spec.js similarity index 100% rename from src/plugins/subscriber/helper.spec.js rename to src/plugins/observable/helper.spec.js diff --git a/src/plugins/subscriber/index.js b/src/plugins/observable/index.js similarity index 94% rename from src/plugins/subscriber/index.js rename to src/plugins/observable/index.js index efb13c6..6224220 100644 --- a/src/plugins/subscriber/index.js +++ b/src/plugins/observable/index.js @@ -31,7 +31,7 @@ const isRelevantChange = (relationships, entity, fn, change) => { isInvalidatedByChange(rel, change); }; -const createSubscriberFactory = (state, relationships, entityConfigs, entity, fn) => (...args) => { +const createObservableFactory = (state, relationships, entityConfigs, entity, fn) => (...args) => { let subscriptions = []; const changeListener = (change) => { @@ -74,7 +74,7 @@ const createSubscriberFactory = (state, relationships, entityConfigs, entity, fn return subscriber; }; -export const subscriber = () => ({ addListener, entityConfigs }) => { +export const observable = () => ({ addListener, entityConfigs }) => { const state = { changeListeners: [] }; @@ -85,7 +85,7 @@ export const subscriber = () => ({ addListener, entityConfigs }) => { return ({ entity, fn }) => { if (fn.operation !== 'READ') { return fn; } - fn.createSubscriber = createSubscriberFactory(state, relationships, entityConfigs, entity, fn); + fn.createObservable = createObservableFactory(state, relationships, entityConfigs, entity, fn); return fn; }; }; diff --git a/src/plugins/subscriber/index.spec.js b/src/plugins/observable/index.spec.js similarity index 81% rename from src/plugins/subscriber/index.spec.js rename to src/plugins/observable/index.spec.js index b35a69e..cf1b662 100644 --- a/src/plugins/subscriber/index.spec.js +++ b/src/plugins/observable/index.spec.js @@ -4,7 +4,7 @@ import sinon from 'sinon'; import { build } from '../../builder'; import { compose, map, toIdMap, values } from '../../fp'; -import { subscriber as plugin } from '.'; +import { observable as plugin } from '.'; const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); const toMiniUser = ({ id, name }) => ({ id, name }); @@ -71,39 +71,39 @@ const createConfig = () => { }; -describe('subscriber plugin', () => { - it('patches fns so that a subscriber can be created on READ operations', () => { +describe('observable plugin', () => { + it('patches fns so that an observable can be created on READ operations', () => { const api = build(createConfig(), [plugin()]); - expect(api.user.getUsers.createSubscriber).to.be.a('function'); - expect(api.user.getUser.createSubscriber).to.be.a('function'); + expect(api.user.getUsers.createObservable).to.be.a('function'); + expect(api.user.getUser.createObservable).to.be.a('function'); }); it('does not patch other operations than read', () => { const api = build(createConfig(), [plugin()]); - expect(api.user.removeUser.createSubscriber).not.to.be; - expect(api.user.updateUser.createSubscriber).not.to.be; + expect(api.user.removeUser.createObservable).not.to.be; + expect(api.user.updateUser.createObservable).not.to.be; }); - describe('createSubscriber()', () => { - it('returns a subscriber shape', () => { + describe('createObservable()', () => { + it('returns an observable shape', () => { const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - expect(subscriber.destroy).to.be.a('function'); - expect(subscriber.subscribe).to.be.a('function'); - expect(subscriber.alive).to.be.true; + const observable = api.user.getUsers.createObservable(); + expect(observable.destroy).to.be.a('function'); + expect(observable.subscribe).to.be.a('function'); + expect(observable.alive).to.be.true; }); }); - describe('subscriber', () => { + describe('observable', () => { describe('destroy', () => { it('removes all subscriptions', () => { const spy1 = sinon.spy(); const spy2 = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - subscriber.subscribe(spy1); - subscriber.subscribe(spy2); + const observable = api.user.getUsers.createObservable(); + observable.subscribe(spy1); + observable.subscribe(spy2); return delay().then(() => { const initialCallCount = spy1.callCount; @@ -112,7 +112,7 @@ describe('subscriber plugin', () => { expect(spy1.callCount).to.equal(initialCallCount + 1); expect(spy2.callCount).to.equal(initialCallCount + 1); - subscriber.destroy(); + observable.destroy(); return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { expect(spy1.callCount).to.equal(initialCallCount + 1); @@ -122,13 +122,13 @@ describe('subscriber plugin', () => { }); }); - it('marks a subscriber as destroyed', () => { + it('marks a observable as destroyed', () => { const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - expect(subscriber.alive).to.be.true; + const observable = api.user.getUsers.createObservable(); + expect(observable.alive).to.be.true; - subscriber.destroy(); - expect(subscriber.alive).to.be.false; + observable.destroy(); + expect(observable.alive).to.be.false; }); }); @@ -136,9 +136,9 @@ describe('subscriber plugin', () => { it('immediately invokes for the first time', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -148,9 +148,9 @@ describe('subscriber plugin', () => { it('returns an unsuscribe function', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - const unsubscribe = subscriber.subscribe(spy); + const unsubscribe = observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -165,9 +165,9 @@ describe('subscriber plugin', () => { it('calls the callback again when a relevant change happens', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -190,9 +190,9 @@ describe('subscriber plugin', () => { config.user.api.getUsers.operation = 'READ'; const api = build(config, [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy, errSpy); + observable.subscribe(spy, errSpy); return delay().then(() => { expect(spy).not.to.have.been.called; @@ -210,9 +210,9 @@ describe('subscriber plugin', () => { config.user.api.getUsers.operation = 'READ'; const api = build(config, [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy, errSpy); + observable.subscribe(spy, errSpy); return delay().then(() => { expect(spy).not.to.have.been.called; @@ -233,8 +233,8 @@ describe('subscriber plugin', () => { const spies = [sinon.spy(), sinon.spy(), sinon.spy()]; const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); - spies.forEach((spy) => subscriber.subscribe(spy)); + const observable = api.user.getUsers.createObservable(); + spies.forEach((spy) => observable.subscribe(spy)); return delay().then(() => { spies.forEach((spy) => expect(spy.callCount).to.equal(1)); @@ -244,9 +244,9 @@ describe('subscriber plugin', () => { it('invokes the callback with no arguments by default', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -265,8 +265,8 @@ describe('subscriber plugin', () => { stub.operation = 'READ'; config.user.api.getUsers = stub; const api = build(config, [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(1, 2, 3); - subscriber.subscribe(spy); + const observable = api.user.getUsers.createObservable(1, 2, 3); + observable.subscribe(spy); return delay().then(() => { expect(stub).to.have.been.calledWith(1, 2, 3); @@ -277,9 +277,9 @@ describe('subscriber plugin', () => { it('notices changes to a child view', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.user.getUsers.createSubscriber(); + const observable = api.user.getUsers.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -293,9 +293,9 @@ describe('subscriber plugin', () => { it('notices changes to a parent view', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.miniUser.getUsers.createSubscriber(); + const observable = api.miniUser.getUsers.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -309,9 +309,9 @@ describe('subscriber plugin', () => { it('notices direct invalidations', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); - const subscriber = api.activity.getActivities.createSubscriber(); + const observable = api.activity.getActivities.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -334,9 +334,9 @@ describe('subscriber plugin', () => { const spy = sinon.spy(); const api = build(config, [plugin()]); - const subscriber = api.activity.getActivities.createSubscriber(); + const observable = api.activity.getActivities.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -374,9 +374,9 @@ describe('subscriber plugin', () => { const spy = sinon.spy(); const api = build(config, [plugin()]); - const subscriber = api.activity.getActivities.createSubscriber(); + const observable = api.activity.getActivities.createObservable(); - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; @@ -406,11 +406,11 @@ describe('subscriber plugin', () => { const spy = sinon.spy(); const api = build(config, [plugin()]); - const subscriber = api.activity.getActivities.createSubscriber(); + const observable = api.activity.getActivities.createObservable(); // fill the cache so that we can remove items later api.user.getUsers().then(() => { - subscriber.subscribe(spy); + observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; From fd7a340a7f329b0e7ec5d602285f49bf1edb2724 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 18 Apr 2017 08:27:14 +0200 Subject: [PATCH 102/136] More naming changes from subscriber to observable --- src/plugins/observable/index.js | 18 +++++++++--------- src/plugins/observable/index.spec.js | 1 + src/release.js | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/plugins/observable/index.js b/src/plugins/observable/index.js index 6224220..f6804b9 100644 --- a/src/plugins/observable/index.js +++ b/src/plugins/observable/index.js @@ -40,28 +40,28 @@ const createObservableFactory = (state, relationships, entityConfigs, entity, fn } fn(...args).then( - (res) => map_((subscription) => subscription.successCb(res), subscriptions), - (err) => map_((subscription) => subscription.errorCb(err), subscriptions) + (res) => map_((subscription) => subscription.onNext(res), subscriptions), + (err) => map_((subscription) => subscription.onError(err), subscriptions) ); }; - const subscriber = { + const observable = { destroy: () => { - subscriber.alive = false; + observable.alive = false; state.changeListeners = removeElement(changeListener, state.changeListeners); subscriptions = []; }, - subscribe: (successCb, errorCb = noop) => { - const subscription = { successCb, errorCb }; + subscribe: (onNext, onError = noop) => { + const subscription = { onNext, onError }; // add ourselves to the subscription list after the first initial call, // so that we don't consume a change we triggered ourselves. fn(...args).then( (res) => { - successCb(res); + onNext(res); subscriptions.push(subscription); }, (err) => { - errorCb(err); + onError(err); subscriptions.push(subscription); } ); @@ -71,7 +71,7 @@ const createObservableFactory = (state, relationships, entityConfigs, entity, fn }; state.changeListeners.push(changeListener); - return subscriber; + return observable; }; export const observable = () => ({ addListener, entityConfigs }) => { diff --git a/src/plugins/observable/index.spec.js b/src/plugins/observable/index.spec.js index cf1b662..216a563 100644 --- a/src/plugins/observable/index.spec.js +++ b/src/plugins/observable/index.spec.js @@ -201,6 +201,7 @@ describe('observable plugin', () => { }); }); + // this is different from e.g. rxjs, which immediately terminates a subscription on error it('also invokes error callback in later stages of the subscription\'s lifecycle', () => { const spy = sinon.spy(); const errSpy = sinon.spy(); diff --git a/src/release.js b/src/release.js index df09895..4e00898 100644 --- a/src/release.js +++ b/src/release.js @@ -1,11 +1,11 @@ import { build } from './builder'; -import { subscriber } from './plugins/subscriber'; +import { observable } from './plugins/observable'; import { denormalizer } from './plugins/denormalizer'; module.exports = { build, plugins: { - subscriber, + observable, denormalizer } }; From bf21407c28a61bcb62eda6e612fcc1cbb3da7192 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Tue, 18 Apr 2017 09:07:24 +0200 Subject: [PATCH 103/136] Return a Disposable from subscribe - a common syntax in e.g. rxjs --- src/plugins/observable/index.js | 2 +- src/plugins/observable/index.spec.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/observable/index.js b/src/plugins/observable/index.js index f6804b9..fc1267a 100644 --- a/src/plugins/observable/index.js +++ b/src/plugins/observable/index.js @@ -65,7 +65,7 @@ const createObservableFactory = (state, relationships, entityConfigs, entity, fn subscriptions.push(subscription); } ); - return () => { subscriptions = removeElement(subscription, subscriptions); }; + return { dispose: () => { subscriptions = removeElement(subscription, subscriptions); } }; }, alive: true }; diff --git a/src/plugins/observable/index.spec.js b/src/plugins/observable/index.spec.js index 216a563..c50639b 100644 --- a/src/plugins/observable/index.spec.js +++ b/src/plugins/observable/index.spec.js @@ -145,16 +145,16 @@ describe('observable plugin', () => { }); }); - it('returns an unsuscribe function', () => { + it('returns a Disposable', () => { const spy = sinon.spy(); const api = build(createConfig(), [plugin()]); const observable = api.user.getUsers.createObservable(); - const unsubscribe = observable.subscribe(spy); + const disposable = observable.subscribe(spy); return delay().then(() => { expect(spy).to.have.been.calledOnce; - unsubscribe(); + disposable.dispose(); return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { expect(spy).to.have.been.calledOnce; From e7873f45164e635f7ce9b635716bf4ce52db90c6 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:32:45 +0200 Subject: [PATCH 104/136] Add prepublish hook --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c58093f..58074ab 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "docs:watch": "npm run docs:prepare && gitbook serve", "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", - "lint": "eslint src" + "lint": "eslint src", + "prepublish": "webpack" }, "author": "Peter Crona (http://www.icecoldcode.com)", "license": "MIT", From e69af554dd9ffd737757d03702063ae30ec903f4 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:36:35 +0200 Subject: [PATCH 105/136] Run webpack in postinstall hook --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58074ab..13e3dcc 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "lint": "eslint src", - "prepublish": "webpack" + "postinstall": "webpack" }, "author": "Peter Crona (http://www.icecoldcode.com)", "license": "MIT", From f61c2e158d44ff3b8db4cd0feb74874dcbbe81df Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:37:26 +0200 Subject: [PATCH 106/136] Update version for next release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 13e3dcc..700f5dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ladda-cache", - "version": "0.1.4", + "version": "0.2.0", "description": "Data fetching layer with support for caching", "main": "dist/bundle.js", "dependencies": {}, From 5072a4de0fc0ad360954bff550045e766c0cd886 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:39:55 +0200 Subject: [PATCH 107/136] Fix postinstall --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 700f5dd..5d3b790 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "lint": "eslint src", - "postinstall": "webpack" + "postinstall": "./node_modules/.bin/webpack --quiet" }, "author": "Peter Crona (http://www.icecoldcode.com)", "license": "MIT", From aa1d813b61a3a365970ac74f7d0bc71e2adcb32f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:47:35 +0200 Subject: [PATCH 108/136] Ok, let's settle for prepublish for real... --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d3b790..7efda66 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test": "env NODE_PATH=$NODE_PATH:$PWD/src ./node_modules/.bin/mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "coverage": "env NODE_PATH=$NODE_PATH:$PWD/src nyc -x '**/*.spec.js' -x '**/*.config.js' --reporter=lcov --reporter=text mocha --compilers js:babel-register --reporter spec src/*.spec.js 'src/**/*.spec.js' --require mocha.config", "lint": "eslint src", - "postinstall": "./node_modules/.bin/webpack --quiet" + "prepublish": "npm run lint && npm test && webpack" }, "author": "Peter Crona (http://www.icecoldcode.com)", "license": "MIT", From 32fd51daf66b8456bf47c7e2b5581c1ad0696ae4 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 08:55:53 +0200 Subject: [PATCH 109/136] Update package info --- package.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7efda66..1f63cc4 100644 --- a/package.json +++ b/package.json @@ -32,11 +32,14 @@ "lint": "eslint src", "prepublish": "npm run lint && npm test && webpack" }, - "author": "Peter Crona (http://www.icecoldcode.com)", + "author": [ + "Peter Crona (http://www.icecoldcode.com)", + "Gernot Hoeflechner <1986gh@gmail.com> (http://github.com/lfdm)" + ], "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/petercrona/ladda.git" + "url": "https://github.com/ladda-js/ladda.git" }, - "homepage": "https://github.com/petercrona/ladda" + "homepage": "https://github.com/ladda-js/ladda" } From 88251f9444b4e67573946d2a57f2d8b1e5e0ee03 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 10:21:06 +0200 Subject: [PATCH 110/136] Update to webpack 2 Also updates babel-loader, because it removes a deprecation warning webpack would otherwise needlessly trigger --- dev_webpack.config.js | 46 +++++++++++++++++++++------------------ package.json | 4 ++-- webpack.config.js | 50 +++++++++++++++++++++++-------------------- 3 files changed, 54 insertions(+), 46 deletions(-) diff --git a/dev_webpack.config.js b/dev_webpack.config.js index 964affc..517fe58 100644 --- a/dev_webpack.config.js +++ b/dev_webpack.config.js @@ -1,25 +1,29 @@ const path = require('path'); module.exports = { - entry: './src/example/index.js', - output: { - path: __dirname + '/dist', - filename: 'bundle.js', - }, - resolve: { - root: path.resolve(__dirname), - modulesDirectories: ['src', 'node_modules'] - }, - module: { - loaders: [ - { - test: /\.jsx?$/, - exclude: /node_modules/, - loader: 'babel', - query: { - presets: ['es2015', 'stage-2'] - } - } - ] - } + entry: './src/example/index.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js' + }, + resolve: { + modules: [ + path.resolve(__dirname, 'src'), + 'node_modules' + ] + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['es2015', 'stage-2'] + } + } + } + ] + } }; diff --git a/package.json b/package.json index 1f63cc4..13a6adc 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "dependencies": {}, "devDependencies": { "babel-core": "^6.9.0", - "babel-loader": "^6.2.4", + "babel-loader": "^7.0.0", "babel-plugin-istanbul": "^4.0.0", "babel-preset-es2015": "^6.9.0", "babel-preset-stage-1": "^6.5.0", @@ -22,7 +22,7 @@ "nyc": "^10.1.2", "sinon": "^1.17.7", "sinon-chai": "^2.8.0", - "webpack": "^1.14.0" + "webpack": "^2.4.1" }, "scripts": { "docs:prepare": "gitbook install", diff --git a/webpack.config.js b/webpack.config.js index 9a0d432..c149c5e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,27 +1,31 @@ const path = require('path'); module.exports = { - entry: './src/release.js', - output: { - path: __dirname + '/dist', - filename: 'bundle.js', - library: 'ladda', - libraryTarget: 'commonjs2' - }, - resolve: { - root: path.resolve(__dirname), - modulesDirectories: ['src', 'node_modules'] - }, - module: { - loaders: [ - { - test: /\.jsx?$/, - exclude: /node_modules/, - loader: 'babel', - query: { - presets: ['es2015', 'stage-2'] - } - } - ] - } + entry: './src/release.js', + output: { + path: path.join(__dirname, 'dist'), + filename: 'bundle.js', + library: 'ladda', + libraryTarget: 'commonjs2' + }, + resolve: { + modules: [ + path.resolve(__dirname, 'src'), + 'node_modules' + ] + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { + presets: ['es2015', 'stage-2'] + } + } + } + ] + } }; From 6fb091e481eadef111657d6820e5afe0d71e3f3a Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 10:24:19 +0200 Subject: [PATCH 111/136] Remove obsolete files --- dev_webpack.config.js | 29 ----------------------------- index.html | 8 -------- 2 files changed, 37 deletions(-) delete mode 100644 dev_webpack.config.js delete mode 100644 index.html diff --git a/dev_webpack.config.js b/dev_webpack.config.js deleted file mode 100644 index 517fe58..0000000 --- a/dev_webpack.config.js +++ /dev/null @@ -1,29 +0,0 @@ -const path = require('path'); - -module.exports = { - entry: './src/example/index.js', - output: { - path: path.join(__dirname, 'dist'), - filename: 'bundle.js' - }, - resolve: { - modules: [ - path.resolve(__dirname, 'src'), - 'node_modules' - ] - }, - module: { - rules: [ - { - test: /\.js$/, - exclude: /node_modules/, - use: { - loader: 'babel-loader', - options: { - presets: ['es2015', 'stage-2'] - } - } - } - ] - } -}; diff --git a/index.html b/index.html deleted file mode 100644 index 6511e4f..0000000 --- a/index.html +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - From c7f48f6ded44252297e68e394ea50132b1d6b732 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 12:16:11 +0200 Subject: [PATCH 112/136] Point badges to ladda-js --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a953a46..eff3ce1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![Ladda](https://smallimprovementstech.files.wordpress.com/2017/03/laddalogo-horiz-color-21.png) -![Build status](https://api.travis-ci.org/petercrona/ladda.svg?branch=master) -[![Coverage Status](https://coveralls.io/repos/github/petercrona/ladda/badge.svg?branch=master&cache=1)](https://coveralls.io/github/petercrona/ladda?branch=master) +![Build status](https://api.travis-ci.org/ladda-js/ladda.svg?branch=master) +[![Coverage Status](https://coveralls.io/repos/github/ladda-js/ladda/badge.svg?branch=master&cache=1)](https://coveralls.io/github/ladda-cache/ladda?branch=master) Ladda is a library that helps you with caching, invalidation of caches and to handle different representations of the same data in a **performant** and **memory efficient** way. It is written in **JavaScript** (ES2015) and designed to be used by **single-page applications**. It is framework agnostic, so it works equally well with React, Vue, Angular or just vanilla JavaScript. From e6af37226f89b8fa8b156ac1b22c4bc226d8b107 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 11:19:05 +0200 Subject: [PATCH 113/136] Use ladda-fp package --- package.json | 4 +- src/builder.js | 2 +- src/builder.spec.js | 2 +- src/decorator/create.js | 2 +- src/decorator/delete.js | 2 +- src/decorator/index.js | 2 +- src/decorator/no-operation.js | 2 +- src/decorator/read.js | 2 +- src/decorator/read.spec.js | 2 +- src/decorator/update.js | 2 +- src/dedup/index.js | 2 +- src/entity-store.js | 2 +- src/fp/index.js | 195 ----------- src/fp/index.spec.js | 455 ------------------------- src/id-helper.js | 2 +- src/listener-store.js | 2 +- src/plugins/denormalizer/index.js | 2 +- src/plugins/denormalizer/index.spec.js | 2 +- src/plugins/observable/index.js | 2 +- src/plugins/observable/index.spec.js | 2 +- src/query-cache.js | 4 +- src/test-helper.js | 2 +- 22 files changed, 23 insertions(+), 671 deletions(-) delete mode 100644 src/fp/index.js delete mode 100644 src/fp/index.spec.js diff --git a/package.json b/package.json index 13a6adc..ef8d39e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "0.2.0", "description": "Data fetching layer with support for caching", "main": "dist/bundle.js", - "dependencies": {}, + "dependencies": { + "ladda-fp": "^0.2.0" + }, "devDependencies": { "babel-core": "^6.9.0", "babel-loader": "^7.0.0", diff --git a/src/builder.js b/src/builder.js index d28426e..8ad919f 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,7 +1,7 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, prop, filterObject, isEqual, not, curry, copyFunction, set - } from './fp'; + } from 'ladda-fp'; import {decorator} from './decorator'; import {dedup} from './dedup'; diff --git a/src/builder.spec.js b/src/builder.spec.js index 8f4825e..2472c82 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -1,8 +1,8 @@ /* eslint-disable no-unused-expressions */ import sinon from 'sinon'; +import {curry} from 'ladda-fp'; import {build} from './builder'; -import {curry} from './fp'; const users = [{ id: 1 }, { id: 2 }]; const getUsers = () => Promise.resolve(users); diff --git a/src/decorator/create.js b/src/decorator/create.js index 186e53a..87382df 100644 --- a/src/decorator/create.js +++ b/src/decorator/create.js @@ -1,6 +1,6 @@ +import {passThrough, compose} from 'ladda-fp'; import {put} from '../entity-store'; import {invalidate} from '../query-cache'; -import {passThrough, compose} from '../fp'; import {addId} from '../id-helper'; export function decorateCreate(c, es, qc, e, aFn) { diff --git a/src/decorator/delete.js b/src/decorator/delete.js index 61663df..4a51a36 100644 --- a/src/decorator/delete.js +++ b/src/decorator/delete.js @@ -1,6 +1,6 @@ +import {passThrough} from 'ladda-fp'; import {remove} from '../entity-store'; import {invalidate} from '../query-cache'; -import {passThrough} from '../fp'; import {serialize} from '../serializer'; export function decorateDelete(c, es, qc, e, aFn) { diff --git a/src/decorator/index.js b/src/decorator/index.js index 1802e91..721de1a 100644 --- a/src/decorator/index.js +++ b/src/decorator/index.js @@ -1,4 +1,4 @@ -import {compose, values} from '../fp'; +import {compose, values} from 'ladda-fp'; import {createEntityStore} from '../entity-store'; import {createQueryCache} from '../query-cache'; import {decorateCreate} from './create'; diff --git a/src/decorator/no-operation.js b/src/decorator/no-operation.js index d4d7c64..55921f7 100644 --- a/src/decorator/no-operation.js +++ b/src/decorator/no-operation.js @@ -1,5 +1,5 @@ +import {passThrough} from 'ladda-fp'; import {invalidate} from '../query-cache'; -import {passThrough} from '../fp'; export function decorateNoOperation(c, es, qc, e, aFn) { return (...args) => { diff --git a/src/decorator/read.js b/src/decorator/read.js index 5c7e519..126b7f7 100644 --- a/src/decorator/read.js +++ b/src/decorator/read.js @@ -1,3 +1,4 @@ +import {passThrough, compose, curry, reduce, toIdMap, map, concat, zip} from 'ladda-fp'; import {get as getFromEs, put as putInEs, mPut as mPutInEs, @@ -7,7 +8,6 @@ import {get as getFromQc, put as putInQc, contains as inQc, getValue} from '../query-cache'; -import {passThrough, compose, curry, reduce, toIdMap, map, concat, zip} from '../fp'; import {addId, removeId} from '../id-helper'; const getTtl = e => e.ttl * 1000; diff --git a/src/decorator/read.spec.js b/src/decorator/read.spec.js index d0071c1..da6c182 100644 --- a/src/decorator/read.spec.js +++ b/src/decorator/read.spec.js @@ -1,11 +1,11 @@ /* eslint-disable no-unused-expressions */ import sinon from 'sinon'; +import {map} from 'ladda-fp'; import {decorateRead} from './read'; import {createEntityStore} from '../entity-store'; import {createQueryCache} from '../query-cache'; import {createSampleConfig, createApiFunction} from '../test-helper'; -import {map} from '../fp'; const config = createSampleConfig(); diff --git a/src/decorator/update.js b/src/decorator/update.js index e347add..349ad78 100644 --- a/src/decorator/update.js +++ b/src/decorator/update.js @@ -1,6 +1,6 @@ +import {passThrough} from 'ladda-fp'; import {put} from '../entity-store'; import {invalidate} from '../query-cache'; -import {passThrough} from '../fp'; import {addId} from '../id-helper'; export function decorateUpdate(c, es, qc, e, aFn) { diff --git a/src/dedup/index.js b/src/dedup/index.js index a02507b..089a2ef 100644 --- a/src/dedup/index.js +++ b/src/dedup/index.js @@ -1,4 +1,4 @@ -import { reduce } from '../fp'; +import { reduce } from 'ladda-fp'; const toKey = (args) => JSON.stringify(args); diff --git a/src/entity-store.js b/src/entity-store.js index b8fbd68..f0772a2 100644 --- a/src/entity-store.js +++ b/src/entity-store.js @@ -11,8 +11,8 @@ * Of course, this also requiers the view to truly be a subset of the entity. */ +import {curry, reduce, map_, clone, noop} from 'ladda-fp'; import {merge} from './merger'; -import {curry, reduce, map_, clone, noop} from './fp'; import {removeId} from './id-helper'; // Value -> StoreValue diff --git a/src/fp/index.js b/src/fp/index.js deleted file mode 100644 index cc07029..0000000 --- a/src/fp/index.js +++ /dev/null @@ -1,195 +0,0 @@ -export const debug = (x) => { - console.log(x); // eslint-disable-line no-console - return x; -}; - -export const identity = x => x; - -export const curry = (f) => (...args) => { - const nrArgsRequired = f.length; - if (args.length < nrArgsRequired) { - return curry(f.bind(null, ...args)); - } - return f(...args); -}; - -export const passThrough = curry((f, x) => { - f(x); - return x; -}); - -export const startsWith = curry((x, xs) => xs.indexOf(x) === 0); - -export const join = curry((separator, x, y) => x + separator + y); - -export const on = curry((f, g, h, x) => f(g(x), h(x))); - -// a -> a -> bool -export const isEqual = curry((x, y) => x === y); - -// bool -> bool -export const not = x => !x; - -export const on2 = curry((f, g, h, x, y) => f(g(x), h(y))); - -export const init = xs => xs.slice(0, xs.length - 1); - -export const tail = xs => xs.slice(1, xs.length); - -export const last = xs => xs[xs.length - 1]; - -export const head = xs => xs[0]; - -export const map = curry((fn, xs) => xs.map(fn)); - -export const map_ = curry((fn, xs) => { map(fn, xs); }); - -export const reverse = xs => xs.slice().reverse(); - -export const reduce = curry((f, currResult, xs) => { - map_((x) => { - currResult = f(currResult, x); - }, xs); - return currResult; -}); - -export const compose = (...fns) => (...args) => - reduce((m, f) => f(m), last(fns)(...args), tail(reverse(fns))); - -export const prop = curry((key, x) => x[key]); - -// [a] -> [b] -> [[a, b]] -export const zip = curry((xs, ys) => { - const toTake = Math.min(xs.length, ys.length); - const zs = []; - for (let i = 0; i < toTake; i++) { - zs.push([xs[i], ys[i]]); - } - return zs; -}); - -// BinaryFn -> BinaryFn -export const flip = fn => curry((x, y) => fn(y, x)); - -// Object -> [[key, val]] -export const toPairs = x => { - const keys = Object.keys(x); - const getValue = flip(prop)(x); - return zip(keys, map(getValue, keys)); -}; - -// [[key, val]] -> Object -export const fromPairs = xs => { - const addToObj = (o, [k, v]) => passThrough(() => { o[k] = v; }, o); - return reduce(addToObj, {}, xs); -}; - -// ([a, b] -> c) -> Map a b -> [c] -export const mapObject = on2(map, identity, toPairs); - -const writeToObject = curry((o, k, v) => { - o[k] = v; - return o; -}); - -// (a -> b) -> Object -> Object -export const mapValues = curry((fn, o) => { - const keys = Object.keys(o); - return reduce((m, x) => { - m[x] = fn(o[x]); - return m; - }, {}, keys); -}); - -// Object -> [b] -export const values = mapObject((pair) => pair[1]); - -// (a -> b) -> [Object] -> Object -export const toObject = curry((getK, xs) => reduce( - (m, x) => writeToObject(m, getK(x), x), - {}, - xs -)); - -const takeIf = curry((p, m, x) => { - if (p(x)) { - m.push(x); - } - return m; -}); - -export const filter = curry((p, xs) => { - return reduce(takeIf(p), [], xs); -}); - -// Object -> Object -export const clone = o => { - if (Array.isArray(o)) { - return o.slice(0); - } - if (typeof o === 'object') { - return {...o}; - } - throw new TypeError('Called with something else than an object/array'); -}; - -// (a -> bool) -> Object -> Object -export const filterObject = curry((p, o) => { - const getKeys = compose(filter(p), Object.keys); - const getProperty = flip(prop); - const keys = getKeys(o); - const createObject = compose(fromPairs, zip(keys), map(getProperty(o))); - - return createObject(keys); -}); - -export const copyFunction = f => { - const newF = f.bind(null); - for (const x in f) { - if (f.hasOwnProperty(x)) { - newF[x] = f[x]; - } - } - return newF; -}; - -export const get = curry((props, o) => { - return reduce((m, p) => { - if (!m) { return m; } - return prop(p, m); - }, o, props); -}); - -export const set = curry((props, val, o) => { - if (!props.length) { return o; } - const update = (items, obj) => { - if (!items.length) { return obj; } - const [k, nextVal] = head(items); - const next = items.length === 1 ? nextVal : { ...prop(k, obj), ...nextVal }; - return { ...obj, [k]: update(tail(items), next) }; - }; - - const zipped = [...map((k) => [k, {}], init(props)), [last(props), val]]; - return update(zipped, o); -}); - -export const concat = curry((a, b) => a.concat(b)); - -export const flatten = (arrs) => reduce(concat, [], arrs); - -export const uniq = (arr) => [...new Set(arr)]; - -export const fst = (arr) => arr[0]; -export const snd = (arr) => arr[1]; - -export const toIdMap = toObject(prop('id')); - -export const noop = () => {}; - -export const removeAtIndex = curry((i, list) => { - const isOutOfBounds = i === -1 || i > list.length - 1; - return isOutOfBounds ? list : [...list.slice(0, i), ...list.slice(i + 1)]; -}); - -export const removeElement = curry((el, list) => removeAtIndex(list.indexOf(el), list)); - diff --git a/src/fp/index.spec.js b/src/fp/index.spec.js deleted file mode 100644 index 6629b24..0000000 --- a/src/fp/index.spec.js +++ /dev/null @@ -1,455 +0,0 @@ -/* eslint-disable no-unused-expressions */ - -import sinon from 'sinon'; -import {debug, identity, curry, passThrough, - startsWith, join, on, isEqual, noop, - on2, init, tail, last, head, map, map_, reverse, - reduce, compose, prop, zip, flip, toPairs, fromPairs, - mapObject, mapValues, toObject, filter, clone, filterObject, - copyFunction, get, set, concat, flatten, uniq, toIdMap, - removeAtIndex, removeElement} from '.'; - -describe('fp', () => { - describe('debug', () => { - it('returns the value it is invoked with', () => { - expect(debug('hello')).to.equal('hello'); - }); - }); - describe('identity', () => { - it('returns the value it is invoked with', () => { - expect(identity('hello')).to.equal('hello'); - }); - }); - describe('curry', () => { - it('returns a function if one of 3 args are provided', () => { - const fn = curry((x, y, z) => x); // eslint-disable-line no-unused-vars - expect(fn('hej')).to.be.a('function'); - }); - it('returns a function if two of 3 args are provided', () => { - const fn = curry((x, y, z) => x); // eslint-disable-line no-unused-vars - expect(fn('hej', 1)).to.be.a('function'); - }); - it('returns a value if all args are provided', () => { - const fn = curry((x, y, z) => x); // eslint-disable-line no-unused-vars - expect(fn('hej', 1, 2)).to.be.equal('hej'); - }); - }); - describe('passThrough', () => { - it('invoke function and return second arg', () => { - const f = sinon.spy(); - expect(passThrough(f, 'hello')).to.equal('hello'); - expect(f.callCount).to.equal(1); - }); - }); - describe('startsWith', () => { - it('return true if first string is prefix of second string', () => { - expect(startsWith('hello', 'hello world')).to.be.true; - }); - it('return false if first string is not prefix of second string', () => { - expect(startsWith('hej', 'hello world')).to.be.false; - }); - it('return true if first arg is first element list', () => { - expect(startsWith(1, [1, 2, 3, 4, 5])).to.be.true; - }); - }); - describe('join', () => { - it('merge two strings with specified separator', () => { - expect(join('-', 'hello', 'world')).to.be.equal('hello-world'); - }); - }); - describe('on', () => { - it('pass final arg thorugh second and thrid fn and pass into first fn', () => { - const f = (x, y) => x + y; - const g = x => x.toUpperCase(); - const h = x => x.toLowerCase(); - expect(on(f, g, h, 'hello')).to.be.equal('HELLOhello'); - }); - }); - describe('isEqual', () => { - it('two elements are equal if same type and same value', () => { - expect(isEqual('hello', 'hello')).to.be.true; - }); - it('two elements are not equal if different type even if same value', () => { - expect(isEqual(1, '1')).to.be.false; - }); - }); - describe('on2', () => { - it('passes two final values through 2nd and 3rd fn to 1st fn', () => { - const f = (x, y) => x + y; - const g = x => x.toUpperCase(); - const h = x => x.toLowerCase(); - expect(on2(f, g, h, 'hello', 'WORLD')).to.be.equal('HELLOworld'); - }); - }); - describe('init', () => { - it('returns all but the last element', () => { - const xs = [1, 2, 3, 4]; - const expected = [1, 2, 3]; - expect(init(xs)).to.deep.equal(expected); - }); - }); - describe('tail', () => { - it('returns all but the first element', () => { - const xs = [1, 2, 3, 4]; - const expected = [2, 3, 4]; - expect(tail(xs)).to.deep.equal(expected); - }); - }); - describe('last', () => { - it('returns the last element', () => { - const xs = [1, 2, 3, 4]; - const expected = 4; - expect(last(xs)).to.equal(expected); - }); - }); - describe('head', () => { - it('returns the first element', () => { - const xs = [1, 2, 3, 4]; - const expected = 1; - expect(head(xs)).to.equal(expected); - }); - }); - describe('map', () => { - it('applies fn to all elements in array', () => { - const xs = [1, 2, 3, 4]; - const add1 = x => x + 1; - const expected = [2, 3, 4, 5]; - expect(map(add1, xs)).to.deep.equal(expected); - }); - }); - describe('map_', () => { - it('applies fn to all elements in array but returns nothing', () => { - const xs = [1, 2, 3, 4]; - const fn = sinon.spy(); - map_(fn, xs); - expect(fn.callCount).to.equal(xs.length); - }); - }); - describe('reverse', () => { - it('reverses elements in array', () => { - const xs = [1, 2, 3, 4]; - const expected = [4, 3, 2, 1]; - expect(reverse(xs)).to.deep.equal(expected); - }); - }); - describe('reduce', () => { - it('joins element with specified base case and operator', () => { - const xs = [1, 2, 3, 4]; - const base = 0; - const add = (x, y) => x + y; - const expected = 10; - expect(reduce(add, base, xs)).to.deep.equal(expected); - }); - }); - describe('compose', () => { - it('passes output from last fn to first after calling last with provided args', () => { - const f = x => x + 1; - const g = x => x + 1; - const fog = compose(f, g); - expect(fog(1)).to.equal(3); - }); - }); - describe('prop', () => { - it('returns the property of an object', () => { - expect(prop('hej', {hej: 'hello'})).to.equal('hello'); - }); - }); - describe('zip', () => { - it('form pairs where indices are equal', () => { - expect(zip([1, 2, 3], [2, 3, 4])).to.deep.equal([[1, 2], [2, 3], [3, 4]]); - }); - it('resulting array shall have same size as first arg array', () => { - expect(zip([1, 2], [2, 3, 4])).to.deep.equal([[1, 2], [2, 3]]); - }); - }); - describe('flip', () => { - it('switch argument order of fn', () => { - const f = flip(prop); - expect(f({hej: 'hello'}, 'hej')).to.equal('hello'); - }); - }); - describe('toPairs', () => { - it('Given an object form pairs with keys and values', () => { - const o = {hello: 'hej', fish: 'fisk'}; - expect(toPairs(o)).to.deep.equal([['hello', 'hej'], ['fish', 'fisk']]); - }); - }); - describe('fromPairs', () => { - it('Given an array of pairs, create an obj where first el is key and second value', () => { - const xs = [['hello', 'hej'], ['fish', 'fisk']]; - expect(fromPairs(xs)).to.deep.equal({hello: 'hej', fish: 'fisk'}); - }); - }); - describe('mapObject', () => { - it('invokes fn with pairs [key, value] from the object', () => { - const xs = { hello: 'hej', fish: 'fisk', cheese: 'ost' }; - expect(mapObject(x => x.join('-'), xs)).to.deep.equal( - ['hello-hej', 'fish-fisk', 'cheese-ost'] - ); - }); - }); - describe('mapValues', () => { - it('only transforms values of object', () => { - const xs = { hello: 'hej', fish: 'fisk', cheese: 'ost' }; - const toUpperCase = x => x.toUpperCase(); - expect(mapValues(toUpperCase, xs)).to.deep.equal( - { hello: 'HEJ', fish: 'FISK', cheese: 'OST' } - ); - }); - }); - describe('toObject', () => { - it('takes a list of objs and transforms to an obj where keys are given by provided fn', () => { - const xs = [{id: 1, name: 'kalle'}, {id: 2, name: 'Erik Ponti'}]; - expect(toObject(prop('id'), xs)).to.deep.equal( - {1: {id: 1, name: 'kalle'}, 2: {id: 2, name: 'Erik Ponti'}} - ); - }); - }); - describe('filter', () => { - it('Only keeps elements for which the predicate returns true', () => { - const xs = [1, 2, 3, 4, 5, 6]; - const largerThan3 = x => x > 3; - expect(filter(largerThan3, xs)).to.deep.equal( - [4, 5, 6] - ); - }); - }); - describe('clone', () => { - it('throws if called with undefined value', () => { - expect(clone.bind(null, undefined)).to.throw(TypeError); - }); - it('clones array', () => { - const o = [1, 2, 3]; - const cloned = clone(o); - o.push(1); - expect(cloned).to.not.deep.equal(o); - }); - it('clones object', () => { - const o = {name: 'kalle'}; - const cloned = clone(o); - o.name = 'ingvar'; - expect(cloned).to.not.deep.equal(o); - }); - }); - describe('filterObject', () => { - it('crash if second parameter is not object', () => { - const fnUnderTest = filterObject.bind(null, () => false, undefined); - expect(fnUnderTest).to.throw(Error); - }); - it('entries for which the predicate returns false are removed', () => { - const input = {1: 'a', 2: 'b', 3: 'c', 4: 'd'}; - const expected = {2: 'b', 4: 'd'}; - const keepEven = filterObject(x => x % 2 === 0); - expect(keepEven(input)).to.deep.equal(expected); - }); - }); - describe('copyFunction', () => { - it('does not take inherited properties', () => { - const input = () => 1; - input.aProp = 'hej'; - input.hasOwnProperty = () => false; - expect(copyFunction(input).aProp).to.be.undefined; - }); - it('mutations on copied function does not cause original to change', () => { - const input = () => 1; - input.aProp = 'hej'; - const res = copyFunction(input); - res.aProp = 'hello'; - expect(input.aProp).to.not.be.equal(res.aProp); - }); - }); - - describe('get', () => { - it('allows to access deep nested data by a list of keys', () => { - const x = { a: { b: { c: 1 } } }; - const actual = get(['a', 'b', 'c'], x); - expect(actual).to.equal(1); - }); - - it('returns undefined when a too deep path is given', () => { - const x = { a: 1 }; - const actual = get(['a', 'b', 'c'], x); - expect(actual).to.be.undefined; - }); - }); - - describe('set', () => { - it('allows to access set nested data by a list of keys and a new value', () => { - const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } }, x: { y: '0' } }; - const nextX = set(keys, 2, x); - expect(get(keys, nextX)).to.equal(2); - }); - - it('returns an immutable copy of the original object', () => { - const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } }, x: { y: '0' } }; - const nextX = set(keys, 2, x); - expect(nextX).not.to.equal(x); - }); - - it('treats all items along the key path as immutable', () => { - const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } }, x: { y: '0' } }; - const nextX = set(keys, 2, x); - expect(nextX.a).not.to.equal(x.a); - expect(nextX.a.b).not.to.equal(x.a.b); - }); - - it('does not update references to obj which are not along the path', () => { - const keys = ['a', 'b', 'c']; - const x = { a: { b: { c: 1 } }, x: { y: '0' } }; - const nextX = set(keys, 2, x); - expect(nextX.x).to.equal(x.x); - }); - - it('returns the original object when the update path is empty', () => { - const x = {}; - const nextX = set([], 1, x); - expect(nextX).to.equal(x); - }); - }); - - describe('concat', () => { - it('concatenates two lists', () => { - const a = [1]; - const b = [2]; - const expected = [1, 2]; - const actual = concat(a, b); - expect(actual).to.deep.equal(expected); - }); - }); - - describe('flatten', () => { - it('flattens a list of lists', () => { - const a = [1]; - const b = [2]; - const c = [3, 4]; - const expected = [1, 2, 3, 4]; - const actual = (flatten([a, b, c])); - expect(actual).to.deep.equal(expected); - }); - }); - - describe('uniq', () => { - it('returns unique items in a list of primitives', () => { - const list = [1, 2, 1, 1, 2, 3]; - const expected = [1, 2, 3]; - const actual = uniq(list); - expect(actual).to.deep.equal(expected); - }); - - it('returns unique items in a list of complex objects', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const list = [a, a, b, a, b, a]; - const expected = [a, b]; - const actual = uniq(list); - expect(actual).to.deep.equal(expected); - }); - }); - - describe('toIdMap', () => { - it('returns a map with ids as keys from a list of entities', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - - const expected = { a, b, c }; - const actual = toIdMap([a, b, c]); - expect(actual).to.deep.equal(expected); - }); - }); - - describe('noop', () => { - it('returns a function that does nothing', () => { - expect(noop).not.to.throw; - expect(noop()).to.be.undefined; - }); - }); - - describe('removeAtIndex', () => { - it('removes an element at a given index', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - const expected = [a, c]; - const actual = removeAtIndex(1, list); - - expect(actual).not.to.equal(list); - expect(actual).to.deep.equal(expected); - }); - - it('does nothing when the index is negative', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - const actual = removeAtIndex(-1, list); - - expect(actual).to.equal(list); - }); - - it('does nothing when the index is out of high bound', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - const actual = removeAtIndex(list.length, list); - - expect(actual).to.equal(list); - }); - - it('is curried', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - const removeAt2 = removeAtIndex(2); - const expected = [a, b]; - const actual = removeAt2(list); - - expect(actual).not.to.equal(list); - expect(actual).to.deep.equal(expected); - }); - }); - - describe('removeElement', () => { - it('removes an element from a list', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - const expected = [a, c]; - const actual = removeElement(b, list); - - expect(actual).not.to.equal(list); - expect(actual).to.deep.equal(expected); - }); - - it('does nothing when the element is not present in the list', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, c]; - const actual = removeElement(b, list); - expect(actual).to.equal(list); - }); - - it('is curried', () => { - const a = { id: 'a' }; - const b = { id: 'b' }; - const c = { id: 'c' }; - const list = [a, b, c]; - - const removeB = removeElement(b); - - const expected = [a, c]; - const actual = removeB(list); - - expect(actual).not.to.equal(list); - expect(actual).to.deep.equal(expected); - }); - }); -}); diff --git a/src/id-helper.js b/src/id-helper.js index df17322..0561ecd 100644 --- a/src/id-helper.js +++ b/src/id-helper.js @@ -1,5 +1,5 @@ +import {curry, map, prop} from 'ladda-fp'; import {serialize} from './serializer'; -import {curry, map, prop} from './fp'; const getIdGetter = (c, aFn) => { if (aFn && aFn.idFrom && typeof aFn.idFrom === 'function') { diff --git a/src/listener-store.js b/src/listener-store.js index c9f98e2..1b982e3 100644 --- a/src/listener-store.js +++ b/src/listener-store.js @@ -1,4 +1,4 @@ -import { curry, map_ } from './fp'; +import { curry, map_ } from 'ladda-fp'; const remove = curry((el, arr) => { const i = arr.indexOf(el); diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js index c063364..fab5108 100644 --- a/src/plugins/denormalizer/index.js +++ b/src/plugins/denormalizer/index.js @@ -2,7 +2,7 @@ import { compose, curry, head, map, mapObject, mapValues, prop, reduce, fromPairs, toPairs, values, uniq, flatten, get, set, snd, toIdMap -} from '../../fp'; +} from 'ladda-fp'; /* TYPES * diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js index 5bc4f85..791eb7f 100644 --- a/src/plugins/denormalizer/index.spec.js +++ b/src/plugins/denormalizer/index.spec.js @@ -2,8 +2,8 @@ import sinon from 'sinon'; +import { curry, head, last, toIdMap, values } from 'ladda-fp'; import { build } from '../../builder'; -import { curry, head, last, toIdMap, values } from '../../fp'; import { denormalizer, extractAccessors } from '.'; const peter = { id: 'peter' }; diff --git a/src/plugins/observable/index.js b/src/plugins/observable/index.js index fc1267a..4bee679 100644 --- a/src/plugins/observable/index.js +++ b/src/plugins/observable/index.js @@ -1,4 +1,4 @@ -import { map_, noop, removeElement } from '../../fp'; +import { map_, noop, removeElement } from 'ladda-fp'; import { analyzeEntityRelationships } from './helper'; const isChangeOfSameEntity = (entity, change) => entity.name === change.entity; diff --git a/src/plugins/observable/index.spec.js b/src/plugins/observable/index.spec.js index c50639b..71279d1 100644 --- a/src/plugins/observable/index.spec.js +++ b/src/plugins/observable/index.spec.js @@ -2,8 +2,8 @@ import sinon from 'sinon'; +import { compose, map, toIdMap, values } from 'ladda-fp'; import { build } from '../../builder'; -import { compose, map, toIdMap, values } from '../../fp'; import { observable as plugin } from '.'; const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); diff --git a/src/query-cache.js b/src/query-cache.js index 60beea5..90c7345 100644 --- a/src/query-cache.js +++ b/src/query-cache.js @@ -3,9 +3,9 @@ * Only ids are stored here. */ -import {mPut as mPutInEs, get as getFromEs} from './entity-store'; import {on2, prop, join, reduce, identity, - curry, map, map_, startsWith, compose, filter} from './fp'; + curry, map, map_, startsWith, compose, filter} from 'ladda-fp'; +import {mPut as mPutInEs, get as getFromEs} from './entity-store'; import {serialize} from './serializer'; // Entity -> [String] -> String diff --git a/src/test-helper.js b/src/test-helper.js index d732ce4..4593d38 100644 --- a/src/test-helper.js +++ b/src/test-helper.js @@ -1,4 +1,4 @@ -import {identity} from './fp'; +import {identity} from 'ladda-fp'; export const createApiFunction = (fn, config = {}) => { const fnCopy = fn.bind(null); From 3de18c30f94c69db61c7fcac32a858d1743dac3f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 11:39:58 +0200 Subject: [PATCH 114/136] Remove observable plugin Moved to its own repo at https://github.com/ladda-js/ladda-observable --- src/plugins/observable/helper.js | 46 --- src/plugins/observable/helper.spec.js | 53 ---- src/plugins/observable/index.js | 92 ------ src/plugins/observable/index.spec.js | 437 -------------------------- 4 files changed, 628 deletions(-) delete mode 100644 src/plugins/observable/helper.js delete mode 100644 src/plugins/observable/helper.spec.js delete mode 100644 src/plugins/observable/index.js delete mode 100644 src/plugins/observable/index.spec.js diff --git a/src/plugins/observable/helper.js b/src/plugins/observable/helper.js deleted file mode 100644 index a052936..0000000 --- a/src/plugins/observable/helper.js +++ /dev/null @@ -1,46 +0,0 @@ - -const getOrCreateContainer = (type, relationships) => { - let container = relationships[type]; - if (!container) { - // eslint-disable-next-line no-multi-assign - container = relationships[type] = { views: [], parents: [], invalidatedBy: [] }; - } - return container; -}; - -const getParentViews = (configs, type, res = []) => { - const config = configs[type]; - if (config.viewOf) { - const parent = config.viewOf; - return getParentViews(configs, parent, [...res, parent]); - } - return res; -}; - -const analyzeViews = (configs, type, rels) => { - const container = getOrCreateContainer(type, rels); - const parents = getParentViews(configs, type); - container.parents = [...container.parents, ...parents]; - parents.forEach((parent) => { - getOrCreateContainer(parent, rels).views.push(type); - }); - return rels; -}; - -const analyzeInvalidations = (configs, rels) => { - Object.keys(rels).forEach((type) => { - const invalidates = configs[type].invalidates || []; - invalidates.forEach((invalidatedType) => { - rels[invalidatedType].invalidatedBy.push(type); - }); - }); - return rels; -}; - -export const analyzeEntityRelationships = (entityConfigs) => { - const relationships = Object.keys(entityConfigs).reduce((rels, type) => { - return analyzeViews(entityConfigs, type, rels); - }, {}); - return analyzeInvalidations(entityConfigs, relationships); -}; - diff --git a/src/plugins/observable/helper.spec.js b/src/plugins/observable/helper.spec.js deleted file mode 100644 index d3002c2..0000000 --- a/src/plugins/observable/helper.spec.js +++ /dev/null @@ -1,53 +0,0 @@ -import { analyzeEntityRelationships } from './helper'; - -describe('subscriber helper', () => { - describe('analyzeEntityRelationships', () => { - it('returns an object to determine parents, views an invalidations per entity', () => { - const config = { - user: { - api: {} - }, - mediumUser: { - api: {}, - viewOf: 'user', - invalidates: ['activity'] - }, - miniUser: { - api: {}, - viewOf: 'mediumUser', - invalidates: ['activity'] - }, - activity: { - api: {} - } - }; - - const expected = { - user: { - views: ['mediumUser', 'miniUser'], - parents: [], - invalidatedBy: [] - }, - mediumUser: { - views: ['miniUser'], - parents: ['user'], - invalidatedBy: [] - }, - miniUser: { - views: [], - parents: ['mediumUser', 'user'], - invalidatedBy: [] - }, - activity: { - views: [], - parents: [], - invalidatedBy: ['mediumUser', 'miniUser'] - } - }; - - const actual = analyzeEntityRelationships(config); - - expect(actual).to.deep.equal(expected); - }); - }); -}); diff --git a/src/plugins/observable/index.js b/src/plugins/observable/index.js deleted file mode 100644 index 4bee679..0000000 --- a/src/plugins/observable/index.js +++ /dev/null @@ -1,92 +0,0 @@ -import { map_, noop, removeElement } from 'ladda-fp'; -import { analyzeEntityRelationships } from './helper'; - -const isChangeOfSameEntity = (entity, change) => entity.name === change.entity; -const isChangeOfView = (rel, change) => rel.views.indexOf(change.entity) !== -1; -const isChangeOfParent = (rel, change) => rel.parents.indexOf(change.entity) !== -1; -const isInvalidatedByChange = (rel, change) => rel.invalidatedBy.indexOf(change.entity) !== -1; - -const isRelevantChange = (relationships, entity, fn, change) => { - // TODO - // - find unobtrusive way to play nice with denormalizer - // - // This could potentially also be optimized. E.g., don't notify when you - // know that you're dealing with an item that is not relevant for you. - // Could be found out by looking at byId and byIds annotations. - // It's not a big deal if this is called again though for now - might - // not be worth the additional complexity. Checking might also take - // just as long as calling the cache again anyway. - // We might however trigger an unnecessary follow up action by consumer - // code (such as re-rendering in an UI), which is not the nicest behavior - // - // In general we are very aggressive here and rather have a false positive - // before we miss out on a change. - // Therefore all related entity changes, also considering invalidations, - // re-trigger here. - // - const rel = relationships[entity.name]; - return isChangeOfSameEntity(entity, change) || - isChangeOfView(rel, change) || - isChangeOfParent(rel, change) || - isInvalidatedByChange(rel, change); -}; - -const createObservableFactory = (state, relationships, entityConfigs, entity, fn) => (...args) => { - let subscriptions = []; - - const changeListener = (change) => { - if (!subscriptions.length || !isRelevantChange(relationships, entity, fn, change)) { - return; - } - - fn(...args).then( - (res) => map_((subscription) => subscription.onNext(res), subscriptions), - (err) => map_((subscription) => subscription.onError(err), subscriptions) - ); - }; - - const observable = { - destroy: () => { - observable.alive = false; - state.changeListeners = removeElement(changeListener, state.changeListeners); - subscriptions = []; - }, - subscribe: (onNext, onError = noop) => { - const subscription = { onNext, onError }; - // add ourselves to the subscription list after the first initial call, - // so that we don't consume a change we triggered ourselves. - fn(...args).then( - (res) => { - onNext(res); - subscriptions.push(subscription); - }, - (err) => { - onError(err); - subscriptions.push(subscription); - } - ); - return { dispose: () => { subscriptions = removeElement(subscription, subscriptions); } }; - }, - alive: true - }; - - state.changeListeners.push(changeListener); - return observable; -}; - -export const observable = () => ({ addListener, entityConfigs }) => { - const state = { - changeListeners: [] - }; - - const relationships = analyzeEntityRelationships(entityConfigs); - - addListener((change) => map_((c) => c(change), state.changeListeners)); - - return ({ entity, fn }) => { - if (fn.operation !== 'READ') { return fn; } - fn.createObservable = createObservableFactory(state, relationships, entityConfigs, entity, fn); - return fn; - }; -}; - diff --git a/src/plugins/observable/index.spec.js b/src/plugins/observable/index.spec.js deleted file mode 100644 index 71279d1..0000000 --- a/src/plugins/observable/index.spec.js +++ /dev/null @@ -1,437 +0,0 @@ -/* eslint-disable no-unused-expressions */ - -import sinon from 'sinon'; - -import { compose, map, toIdMap, values } from 'ladda-fp'; -import { build } from '../../builder'; -import { observable as plugin } from '.'; - -const delay = (t = 1) => new Promise(res => setTimeout(() => res(), t)); -const toMiniUser = ({ id, name }) => ({ id, name }); - -const createUserApi = (container) => { - const getUser = (id) => Promise.resolve(container[id]); - getUser.operation = 'READ'; - getUser.byId = true; - const getUsers = () => Promise.resolve(values(container)); - getUsers.operation = 'READ'; - - const updateUser = (nextUser) => { - const { id } = nextUser; - const user = container[id]; - container[id] = { ...user, ...nextUser }; - return Promise.resolve(container[id]); - }; - updateUser.operation = 'UPDATE'; - - const createUser = (user) => { - container[user.id] = user; - return Promise.resolve(user); - }; - createUser.operation = 'CREATE'; - - const removeUser = (id) => { - delete container[id]; - return Promise.resolve(); - }; - removeUser.operation = 'DELETE'; - - return { getUser, getUsers, createUser, updateUser, removeUser }; -}; - -const createConfig = () => { - const peter = { id: 'peter', name: 'peter', location: 'gothenburg' }; - const gernot = { id: 'gernot', name: 'gernot', location: 'graz' }; - const robin = { id: 'robin', name: 'robin', location: 'berlin' }; - - const list = [peter, gernot, robin]; - const users = toIdMap(list); - const miniUsers = compose(toIdMap, map(toMiniUser))(list); - - const getActivities = () => Promise.resolve([]); - getActivities.operation = 'READ'; - - return { - user: { - api: createUserApi(users) - }, - mediumUser: { - api: createUserApi(users), - viewOf: 'user', - invalidates: ['activity'] - }, - miniUser: { - api: createUserApi(miniUsers), - viewOf: 'mediumUser' - }, - activity: { - api: { getActivities } - } - }; -}; - - -describe('observable plugin', () => { - it('patches fns so that an observable can be created on READ operations', () => { - const api = build(createConfig(), [plugin()]); - expect(api.user.getUsers.createObservable).to.be.a('function'); - expect(api.user.getUser.createObservable).to.be.a('function'); - }); - - it('does not patch other operations than read', () => { - const api = build(createConfig(), [plugin()]); - expect(api.user.removeUser.createObservable).not.to.be; - expect(api.user.updateUser.createObservable).not.to.be; - }); - - describe('createObservable()', () => { - it('returns an observable shape', () => { - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - expect(observable.destroy).to.be.a('function'); - expect(observable.subscribe).to.be.a('function'); - expect(observable.alive).to.be.true; - }); - }); - - describe('observable', () => { - describe('destroy', () => { - it('removes all subscriptions', () => { - const spy1 = sinon.spy(); - const spy2 = sinon.spy(); - const api = build(createConfig(), [plugin()]); - - const observable = api.user.getUsers.createObservable(); - observable.subscribe(spy1); - observable.subscribe(spy2); - - return delay().then(() => { - const initialCallCount = spy1.callCount; - - return api.user.updateUser({ id: 'peter', name: 'Peter' }).then(() => { - expect(spy1.callCount).to.equal(initialCallCount + 1); - expect(spy2.callCount).to.equal(initialCallCount + 1); - - observable.destroy(); - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy1.callCount).to.equal(initialCallCount + 1); - expect(spy2.callCount).to.equal(initialCallCount + 1); - }); - }); - }); - }); - - it('marks a observable as destroyed', () => { - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - expect(observable.alive).to.be.true; - - observable.destroy(); - expect(observable.alive).to.be.false; - }); - }); - - describe('subscribe', () => { - it('immediately invokes for the first time', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - }); - }); - - it('returns a Disposable', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - - const disposable = observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - disposable.dispose(); - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledOnce; - }); - }); - }); - - it('calls the callback again when a relevant change happens', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - return api.user.updateUser({ id: 'peter', name: 'PETer' }).then(() => { - expect(spy).to.have.been.calledThrice; - }); - }); - }); - }); - - it('takes an optional second callback invoked on error', () => { - const spy = sinon.spy(); - const errSpy = sinon.spy(); - const error = { err: 'x' }; - const config = createConfig(); - config.user.api.getUsers = () => Promise.reject(error); - config.user.api.getUsers.operation = 'READ'; - - const api = build(config, [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy, errSpy); - - return delay().then(() => { - expect(spy).not.to.have.been.called; - expect(errSpy).to.have.been.calledOnce; - expect(errSpy).to.have.been.calledWith(error); - }); - }); - - // this is different from e.g. rxjs, which immediately terminates a subscription on error - it('also invokes error callback in later stages of the subscription\'s lifecycle', () => { - const spy = sinon.spy(); - const errSpy = sinon.spy(); - const error = { err: 'x' }; - const config = createConfig(); - config.user.api.getUsers = () => Promise.reject(error); - config.user.api.getUsers.operation = 'READ'; - - const api = build(config, [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy, errSpy); - - return delay().then(() => { - expect(spy).not.to.have.been.called; - expect(errSpy).to.have.been.calledOnce; - expect(errSpy).to.have.been.calledWith(error); - - return api.user.createUser({ id: 'x', name: 'y' }).then(() => { - return delay().then(() => { - expect(spy).not.to.have.been.called; - expect(errSpy).to.have.been.calledTwice; - }); - }); - }); - }); - - // eslint-disable-next-line max-len - it('several parallel subscriptions guarantee to call each subscription only once initially', () => { - const spies = [sinon.spy(), sinon.spy(), sinon.spy()]; - const api = build(createConfig(), [plugin()]); - - const observable = api.user.getUsers.createObservable(); - spies.forEach((spy) => observable.subscribe(spy)); - - return delay().then(() => { - spies.forEach((spy) => expect(spy.callCount).to.equal(1)); - }); - }); - - it('invokes the callback with no arguments by default', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - }); - }); - }); - - it('takes the arguments of the create call and passes them to the api call', () => { - const config = createConfig(); - const stub = sinon.stub(); - const spy = sinon.spy(); - stub.returns(Promise.resolve([])); - stub.operation = 'READ'; - config.user.api.getUsers = stub; - const api = build(config, [plugin()]); - const observable = api.user.getUsers.createObservable(1, 2, 3); - observable.subscribe(spy); - - return delay().then(() => { - expect(stub).to.have.been.calledWith(1, 2, 3); - }); - }); - - describe('with views', () => { - it('notices changes to a child view', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.user.getUsers.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.miniUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - }); - }); - }); - - it('notices changes to a parent view', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.miniUser.getUsers.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.user.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - expect(spy).to.have.been.calledTwice; - }); - }); - }); - - it('notices direct invalidations', () => { - const spy = sinon.spy(); - const api = build(createConfig(), [plugin()]); - const observable = api.activity.getActivities.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - return delay().then(() => { - expect(spy).to.have.been.calledThrice; - }); - }); - }); - }); - - it('calls api fns only AFTER they have been invalidated (on update)', () => { - const config = createConfig(); - const state = { - activities: [{ id: 'a' }] - }; - config.activity.api.getActivities = () => Promise.resolve(state.activities); - config.activity.api.getActivities.operation = 'READ'; - - const spy = sinon.spy(); - const api = build(config, [plugin()]); - const observable = api.activity.getActivities.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - const firstArgs = spy.args[0]; - expect(firstArgs.length).to.equal(1); - - state.activities = [...state.activities, { id: 'b' }]; - - return api.mediumUser.updateUser({ id: 'peter', name: 'PEter' }).then(() => { - return delay().then(() => { - // TODO - // Ideally this should be called only twice: - // 1. The initial subscription - // 2. The call after the invalidation by updateUser - // - // The third call happens after 2. - as this call also creates - // an update event. The subscription is consuming its own event. - // Not ideal, but not trivial to fix. - expect(spy).to.have.been.calledThrice; - const secondArgs = spy.args[1]; - expect(secondArgs[0].length).to.equal(2); - }); - }); - }); - }); - - it('calls api fns only AFTER they have been invalidated (on create)', () => { - const config = createConfig(); - const state = { - activities: [{ id: 'a' }] - }; - config.activity.api.getActivities = () => Promise.resolve(state.activities); - config.activity.api.getActivities.operation = 'READ'; - - const spy = sinon.spy(); - const api = build(config, [plugin()]); - const observable = api.activity.getActivities.createObservable(); - - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - const firstArgs = spy.args[0]; - expect(firstArgs.length).to.equal(1); - - state.activities = [...state.activities, { id: 'b' }]; - - return api.mediumUser.createUser({ id: 'timur', name: 'Timur' }).then(() => { - return delay().then(() => { - expect(spy).to.have.been.calledThrice; - const secondArgs = spy.args[1]; - expect(secondArgs[0].length).to.equal(2); - }); - }); - }); - }); - - it('calls api fns only AFTER they have been invalidated (on remove)', () => { - const config = createConfig(); - const state = { - activities: [{ id: 'a' }] - }; - config.activity.api.getActivities = () => Promise.resolve(state.activities); - config.activity.api.getActivities.operation = 'READ'; - - const spy = sinon.spy(); - const api = build(config, [plugin()]); - const observable = api.activity.getActivities.createObservable(); - - // fill the cache so that we can remove items later - api.user.getUsers().then(() => { - observable.subscribe(spy); - - return delay().then(() => { - expect(spy).to.have.been.calledOnce; - - const firstArgs = spy.args[0]; - expect(firstArgs.length).to.equal(1); - - state.activities = [...state.activities, { id: 'b' }]; - - return api.mediumUser.removeUser('peter').then(() => { - return delay().then(() => { - expect(spy).to.have.been.calledThrice; - const secondArgs = spy.args[1]; - expect(secondArgs[0].length).to.equal(2); - }); - }); - }); - }); - }); - }); - }); - }); -}); From 1627a345a9706bfc271f841649bb84f93e1cb806 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 11:58:01 +0200 Subject: [PATCH 115/136] Remove denormalizer plugin Moved to own repository at https://github.com/ladda-js/ladda-denormalizer --- src/plugins/denormalizer/index.js | 152 ------- src/plugins/denormalizer/index.spec.js | 565 ------------------------- 2 files changed, 717 deletions(-) delete mode 100644 src/plugins/denormalizer/index.js delete mode 100644 src/plugins/denormalizer/index.spec.js diff --git a/src/plugins/denormalizer/index.js b/src/plugins/denormalizer/index.js deleted file mode 100644 index fab5108..0000000 --- a/src/plugins/denormalizer/index.js +++ /dev/null @@ -1,152 +0,0 @@ -import { - compose, curry, head, map, mapObject, mapValues, - prop, reduce, fromPairs, toPairs, values, - uniq, flatten, get, set, snd, toIdMap -} from 'ladda-fp'; - -/* TYPES - * - * Path = [String] - * - * Accessors = [ (Path, Type | [Type]) ] - * - * Fetcher = { - * getOne: id -> Promise Entity - * getSome: [id] -> Promise [Entity] - * getAll: Promise [Entity] - * threshold: Int - * } - * - */ - -export const NAME = 'denormalizer'; - -const def = curry((a, b) => b || a); - -const getApi = curry((configs, entityName) => compose(prop('api'), prop(entityName))(configs)); - -const getPluginConf_ = curry((config) => compose(prop(NAME), def({}), prop('plugins'))(config)); - -const getSchema_ = (config) => compose(prop('schema'), def({}), getPluginConf_)(config); - -const getPluginConf = curry((cs, entityName) => getPluginConf_(cs[entityName])); - -const collectTargets = curry((accessors, res, item) => { - return reduce((m, [path, type]) => { - let list = m[type]; - if (!list) { list = []; } - const val = get(path, item); - if (Array.isArray(val)) { - list = list.concat(val); - } else { - list.push(val); - } - m[type] = list; - return m; - }, res, accessors); -}); - -const resolveItem = curry((accessors, entities, item) => { - return reduce((m, [path, type]) => { - const val = get(path, item); - const getById = (id) => entities[type][id]; - const resolvedVal = Array.isArray(val) ? map(getById, val) : getById(val); - return set(path, resolvedVal, m); - }, item, accessors); -}); - -const resolveItems = curry((accessors, items, entities) => { - return map(resolveItem(accessors, entities), items); -}); - -const requestEntities = curry(({ getOne, getSome, getAll, threshold }, ids) => { - const noOfItems = ids.length; - - if (noOfItems === 1) { - return getOne(ids[0]).then((e) => [e]); - } - if (noOfItems > threshold && getAll) { - return getAll(); - } - return getSome(ids); -}); - -const resolve = curry((fetchers, accessors, items) => { - const requestsToMake = compose(reduce(collectTargets(accessors), {}))(items); - return Promise.all(mapObject(([t, ids]) => { - return requestEntities(fetchers[t], ids).then((es) => [t, es]); - }, requestsToMake)).then( - compose(resolveItems(accessors, items), mapValues(toIdMap), fromPairs) - ); -}); - -const parseSchema = (schema) => { - return reduce((m, [field, val]) => { - if (Array.isArray(val) || typeof val === 'string') { - m[field] = val; - } else { - const nextSchema = parseSchema(val); - Object.keys(nextSchema).forEach((k) => { - m[[field, k].join('.')] = nextSchema[k]; - }); - } - return m; - }, {}, toPairs(schema)); -}; - - -// EntityConfigs -> Map String Accessors -export const extractAccessors = (configs) => { - const asMap = reduce((m, c) => { - const schema = getSchema_(c); - if (schema) { m[c.name] = parseSchema(schema); } - return m; - }, {}, configs); - return mapValues(compose(map(([ps, v]) => [ps.split('.'), v]), toPairs))(asMap); -}; - -// PluginConfig -> EntityConfigs -> [Type] -> Map Type Fetcher -const extractFetchers = (pluginConfig, configs, types) => { - return compose(fromPairs, map((t) => { - const conf = getPluginConf(configs, t); - const api = getApi(configs, t); - if (!conf) { - throw new Error(`No denormalizer config found for type ${t}`); - } - - const fromApi = (p) => api[conf[p]]; - const getOne = fromApi('getOne'); - const getSome = fromApi('getSome') || ((is) => Promise.all(map(getOne, is))); - const getAll = fromApi('getAll'); - const threshold = conf.threshold || pluginConfig.threshold || Infinity; - - if (!getOne) { - throw new Error(`No 'getOne' accessor defined on type ${t}`); - } - return [t, { getOne, getSome, getAll, threshold }]; - }))(types); -}; - -// Map Type Accessors -> [Type] -const extractTypes = compose(uniq, flatten, map(snd), flatten, values); - -export const denormalizer = (pluginConfig = {}) => ({ entityConfigs }) => { - const allAccessors = extractAccessors(values(entityConfigs)); - const allFetchers = extractFetchers(pluginConfig, entityConfigs, extractTypes(allAccessors)); - - return ({ entity, fn }) => { - const accessors = allAccessors[entity.name]; - if (!accessors) { - return fn; - } - return (...args) => { - return fn(...args).then((res) => { - const isArray = Array.isArray(res); - const items = isArray ? res : [res]; - - const resolved = resolve(allFetchers, accessors, items); - return isArray ? resolved : resolved.then(head); - }); - }; - }; -}; diff --git a/src/plugins/denormalizer/index.spec.js b/src/plugins/denormalizer/index.spec.js deleted file mode 100644 index 791eb7f..0000000 --- a/src/plugins/denormalizer/index.spec.js +++ /dev/null @@ -1,565 +0,0 @@ -/* eslint-disable no-unused-expressions */ - -import sinon from 'sinon'; - -import { curry, head, last, toIdMap, values } from 'ladda-fp'; -import { build } from '../../builder'; -import { denormalizer, extractAccessors } from '.'; - -const peter = { id: 'peter' }; -const gernot = { id: 'gernot' }; -const robin = { id: 'robin' }; - -const users = toIdMap([peter, gernot, robin]); - -const c1 = { id: 'a' }; -const c2 = { id: 'b' }; - -const comments = toIdMap([c1, c2]); - -const m1 = { - id: 'x', - author: peter.id, - recipient: gernot.id, - visibleTo: [robin.id], - nestedData: { - comments: [c1.id, c2.id] - } -}; -const m2 = { - id: 'y', - author: gernot.id, - recipient: peter.id, - visibleTo: [], - nestedData: { - comments: [] - } -}; - -const messages = toIdMap([m1, m2]); - -const getById = curry((m, id) => Promise.resolve(m[id])); -const getAll = (m) => () => Promise.resolve(values(m)); - -const getUser = getById(users); -getUser.operation = 'READ'; -getUser.byId = true; -const getUsers = getAll(users); -getUser.operation = 'READ'; - -const getMessage = getById(messages); -getMessage.operation = 'READ'; -getMessage.byId = true; -const getMessages = getAll(messages); -getMessages.operation = 'READ'; - -const getComment = getById(comments); -getComment.operation = 'READ'; -getComment.byId = true; -const getComments = getAll(comments); -getComments.operation = 'READ'; - - -const config = () => ({ - user: { - api: { getUser, getUsers }, - plugins: { - denormalizer: { - getOne: 'getUser', - getAll: 'getUsers', - threshold: 5 - } - } - }, - message: { - api: { getMessage, getMessages }, - plugins: { - denormalizer: { - schema: { - author: 'user', - recipient: 'user', - visibleTo: ['user'], - nestedData: { - comments: ['comment'] - } - } - } - } - }, - comment: { - api: { getComment, getComments }, - plugins: { - denormalizer: { - getOne: 'getComment', - getAll: 'getComments' - } - } - } -}); - -const expectResolved = curry((k, val, obj) => { - expect(obj[k]).to.deep.equal(val); - return obj; -}); - -describe('denormalizer', () => { - describe('with a fn, that returns one object', () => { - it('resolves references to simple id fields', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessage(m1.id) - .then(expectResolved('author', users[m1.author])) - .then(expectResolved('recipient', users[m1.recipient])) - .then(() => done()); - }); - - it('resolves references to lists of ids', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessage(m1.id) - .then(expectResolved('visibleTo', [users[m1.visibleTo[0]]])) - .then(() => done()); - }); - - it('resolves references for nested data', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessage(m1.id) - .then((m) => expectResolved('comments', [c1, c2], m.nestedData)) - .then(() => done()); - }); - - it('fails immediately when no config for an entity present in a schema is defined', () => { - const conf = { - user: { - api: {} - }, - message: { - api: {}, - plugins: { - denormalizer: { - schema: { - author: 'user' - } - } - } - } - }; - - const start = () => build(conf, [denormalizer()]); - expect(start).to.throw(/no.*config.*user/i); - }); - - it('fails immediately when config for an entity present in a schema is incomplete', () => { - const conf = { - user: { - api: {}, - plugins: { - denormalizer: {} - } - }, - message: { - api: {}, - plugins: { - denormalizer: { - schema: { - author: 'user' - } - } - } - } - }; - - const start = () => build(conf, [denormalizer()]); - expect(start).to.throw(/no.*getOne.*user/i); - }); - - it('calls getOne when there is only one entity to resolve', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve()); - const authorId = 'x'; - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome' - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: [authorId] }) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getOneSpy).to.have.been.calledOnce; - expect(getOneSpy).to.have.been.calledWith(authorId); - expect(getSomeSpy).not.to.have.been.called; - }); - }); - - it('calls getAll when multiple items requested is above threshold', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve([])); - const getAllSpy = sinon.spy(() => Promise.resolve([])); - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy, - getAll: getAllSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome', - getAll: 'getAll', - threshold: 2 - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getSomeSpy).not.to.have.been.called; - expect(getAllSpy).to.have.been.calledOnce; - }); - }); - - it('calls getSome when multiple items requested are below threshold', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve([])); - const getAllSpy = sinon.spy(() => Promise.resolve([])); - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy, - getAll: getAllSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome', - getAll: 'getAll', - threshold: 3 - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getSomeSpy).to.have.been.called; - expect(getSomeSpy).to.have.been.calledWith(['a', 'b']); - expect(getAllSpy).not.to.have.been.calledOnce; - }); - }); - - it('calls getSome when multiple items requested are at threshold', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve([])); - const getAllSpy = sinon.spy(() => Promise.resolve([])); - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy, - getAll: getAllSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome', - getAll: 'getAll', - threshold: 2 - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getSomeSpy).to.have.been.called; - expect(getAllSpy).not.to.have.been.calledOnce; - }); - }); - - it('calls getSome when items requested are above threshold, but no getAll present', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve([])); - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome', - threshold: 1 - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getSomeSpy).to.have.been.called; - expect(getSomeSpy).to.have.been.calledWith(['a', 'b']); - }); - }); - - it('calls getOne several times when there is nothing else defined', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - - const conf = { - user: { - api: { - getOne: getOneSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne' - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer()]); - return api.message.get().then(() => { - expect(getOneSpy).to.have.been.calledTwice; - expect(getOneSpy).to.have.been.calledWith('a'); - expect(getOneSpy).to.have.been.calledWith('b'); - }); - }); - - it('allows to define a global threshold', () => { - const getOneSpy = sinon.spy(() => Promise.resolve({ id: 'a' })); - const getSomeSpy = sinon.spy(() => Promise.resolve([])); - const getAllSpy = sinon.spy(() => Promise.resolve([])); - - const conf = { - user: { - api: { - getOne: getOneSpy, - getSome: getSomeSpy, - getAll: getAllSpy - }, - plugins: { - denormalizer: { - getOne: 'getOne', - getSome: 'getSome', - getAll: 'getAll' - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - } - }; - - const api = build(conf, [denormalizer({ threshold: 2 })]); - return api.message.get().then(() => { - expect(getSomeSpy).not.to.have.been.called; - expect(getAllSpy).to.have.been.calledOnce; - }); - }); - - it('does not fall when entities are unused and have no conf defined', () => { - const conf = { - user: { - api: { - getOne: () => Promise.resolve() - }, - plugins: { - denormalizer: { - getOne: 'getOne' - } - } - }, - message: { - api: { - get: () => Promise.resolve({ authors: ['a', 'b', 'c']}) - }, - plugins: { - denormalizer: { - schema: { - authors: ['user'] - } - } - } - }, - comment: { - api: { - get: () => Promise.resolve() - } - } - }; - - const start = () => build(conf, [denormalizer()]); - expect(start).not.to.throw; - }); - }); - - describe('with a fn, that returns a list of objects', () => { - it('resolves references to simple id fields', (done) => { - const api = build(config(), [denormalizer()]); - api.message.getMessages() - .then((msgs) => { - const fst = head(msgs); - const snd = last(msgs); - expectResolved('author', users[m1.author])(fst); - expectResolved('recipient', users[m1.recipient])(fst); - - expectResolved('author', users[m2.author])(snd); - expectResolved('recipient', users[m2.recipient])(snd); - }) - .then(() => done()); - }); - }); -}); - -describe('denormalization-helpers', () => { - const createConfig = () => [ - { - name: 'message', - plugins: { - denormalizer: { - schema: { - author: 'user', - recipient: 'user', - visibleTo: ['user'], - nestedData: { - comments: ['comment'] - } - } - } - } - }, - { - name: 'review', - plugins: { - denormalizer: { - schema: { - author: 'user', - meta: { - data: { - comments: ['comment'] - } - } - } - } - } - } - ]; - - describe('extractAccessors', () => { - it('parses config and returns all paths to entities defined in schemas', () => { - const expected = { - message: [ - [['author'], 'user'], - [['recipient'], 'user'], - [['visibleTo'], ['user']], - [['nestedData', 'comments'], ['comment']] - ], - review: [ - [['author'], 'user'], - [['meta', 'data', 'comments'], ['comment']] - ] - }; - - const actual = extractAccessors(createConfig()); - expect(actual).to.deep.equal(expected); - }); - }); -}); - From ea04f2574593a374ca1400c79897267c9cce1f44 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sat, 22 Apr 2017 12:20:34 +0200 Subject: [PATCH 116/136] Fix release module - remove plugins --- src/release.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/release.js b/src/release.js index 4e00898..3302f10 100644 --- a/src/release.js +++ b/src/release.js @@ -1,11 +1,3 @@ import { build } from './builder'; -import { observable } from './plugins/observable'; -import { denormalizer } from './plugins/denormalizer'; -module.exports = { - build, - plugins: { - observable, - denormalizer - } -}; +module.exports = { build }; From 22fd0e285fd352e167acbaa09bbdef56eff1f9ec Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 08:31:37 +0200 Subject: [PATCH 117/136] Start with outline of Plugin docs --- docs/README.md | 3 ++ docs/advanced/Plugins-CheatSheet.md | 49 +++++++++++++++++++++++++++ docs/advanced/Plugins-KnownPlugins.md | 31 +++++++++++++++++ docs/advanced/Plugins.md | 15 ++++++++ 4 files changed, 98 insertions(+) create mode 100644 docs/advanced/Plugins-CheatSheet.md create mode 100644 docs/advanced/Plugins-KnownPlugins.md create mode 100644 docs/advanced/Plugins.md diff --git a/docs/README.md b/docs/README.md index b5badaa..0b9e6b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,9 @@ * [Invalidation](/docs/advanced/Invalidation.md) * [Views](/docs/advanced/ViewOf.md) * [Custom ID](/docs/advanced/CustomId.md) + * [Plugins](/docs/advanced/Plugins.md) + * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) + * [Known Plugins](/docs/advanced/Plugins-KnownPlugins.md) * [Recipes](/docs/Recipes.md) * [Separate API Folder](/docs/recipes/SeparateApiFolder.md) * [Polling](/docs/recipes/Polling.md) diff --git a/docs/advanced/Plugins-CheatSheet.md b/docs/advanced/Plugins-CheatSheet.md new file mode 100644 index 0000000..e7f4317 --- /dev/null +++ b/docs/advanced/Plugins-CheatSheet.md @@ -0,0 +1,49 @@ +# Plugins Cheat Sheet + +## Signature + +```javascript +({ entityConfigs, config, addListener }) => ({ entity, fn }) => ApiFn +``` + +
+ +It is a good practice to allow your users to pass in an additional +plugin configuration object, thus reaching a final shape like this: + +```javascript +export const yourPlugin = (pluginConfig) => { + return ({ entityConfigs, config, addListener }) => { + // Use this space to setup additional data structures and helpers, + // that act across entities. + return ({ entity, fn }) => { + // Use this space to setup additional data structures and helpers, + // that act on a single entity. + return (...args) => { + // Do your magic here! + // Invoke the original fn with its arguments or a variation of it. + return fn(...args); + } + } + } +}; +``` + +## Apply a plugin + +Pass plugins in a list of plugins as an optional second argument to +Ladda's `build` function. + +```javascript +import { build } from 'ladda-cache'; +import { logger } from 'ladda-logger'; +import { observable } from 'ladda-observable'; + +const config = { /* your ladda configuration */ }; + +export default build(config, [ + logger(), + observable() +]); +``` + diff --git a/docs/advanced/Plugins-KnownPlugins.md b/docs/advanced/Plugins-KnownPlugins.md new file mode 100644 index 0000000..41da174 --- /dev/null +++ b/docs/advanced/Plugins-KnownPlugins.md @@ -0,0 +1,31 @@ +# Known Plugins + +
+
+ Just created your own Ladda plugin? +
+
+ Feel free to add it to this list and share it with the community! +
+
+ +- [ladda-logger](https://github.com/ladda-js/ladda-logger) + +A more sophisticated version of the plugin we just build. Logs on every +change to the Ladda cache and gives you timings on how long your API +calls take. + +- [ladda-observable](https://github.com/ladda-js/ladda-observable) + +Adds an observable interface to all `READ` operations. Allows you to be +notified whenever something related to your API call has changed, e.g. +you can observe a list of entities and get notified once one of this +changes is updated. + +- [ladda-denormalizer](https://github.com/ladda-js/ladda-denormalizer) + +Allows to define denormalization schemas and strategies, so that your +server can send plain ids instead of full entity objects. The plugin +will resolve these ids for you in an optimized fashion, so that your +client-side code can stay simple an operate on full entities. + diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md new file mode 100644 index 0000000..dbb900b --- /dev/null +++ b/docs/advanced/Plugins.md @@ -0,0 +1,15 @@ +# Plugins + +Ladda was built with extensibility in mind and features a powerful +plugin API to build additional functionality on top of its simple core. + +Under the hood Ladda's core functionality (caching, views and +invalidation) is implemented using this API as well. + +Check out the [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) to get an +overview on a single glance and take a look at our [curated list of +already built plugins](/docs/advanced/Plugins-KnownPlugins.md). + +## Building a simple logger plugin + + From 0362816b3fdb10bc336a08e731a420a275cf9f6b Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 09:30:42 +0200 Subject: [PATCH 118/136] More plugin docs --- docs/advanced/Plugins-CheatSheet.md | 6 ++- docs/advanced/Plugins.md | 81 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docs/advanced/Plugins-CheatSheet.md b/docs/advanced/Plugins-CheatSheet.md index e7f4317..d1ec990 100644 --- a/docs/advanced/Plugins-CheatSheet.md +++ b/docs/advanced/Plugins-CheatSheet.md @@ -42,8 +42,10 @@ import { observable } from 'ladda-observable'; const config = { /* your ladda configuration */ }; export default build(config, [ - logger(), - observable() + observable(), + logger() ]); ``` +Plugins are evaluated from left to right. + diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md index dbb900b..fa0254e 100644 --- a/docs/advanced/Plugins.md +++ b/docs/advanced/Plugins.md @@ -12,4 +12,85 @@ already built plugins](/docs/advanced/Plugins-KnownPlugins.md). ## Building a simple logger plugin +At its core a plugin is a higher order function, which returns a mapping +function, which is invoked for each __ApiFunction__ you specified in +your Ladda configuration - it is is supposed to return a +new enhanced version of the given ApiFunction. +Let's start with the minimal boilerplate, which is needed to get a +plugin off the ground and then discuss each step in more detail. + +```javascript +export const logger = (pluginConfig) => { + return ({ entityConfigs, config, addListener }) => { + return ({ entity, fn }) => { + return (...args) => { + return fn(...args); + } + } + } +}; +``` + +This is the final version of our simple logger plugin: + +```javascript +export const logger = (pluginConfig) => { + return ({ entityConfigs, config, addListener }) => { + console.log('Ladda: Setup in progress', entityConfigs, config); + + addListener((change) => console.log('Ladda: Cache change', change)); + + return ({ entity, fn }) => { + return (...args) => { + console.log(`Ladda: Calling ${entity.name}.${fn.name} with args`, args); + return fn(...args).then( + (res) => { + console.log(`Ladda: Resolved ${entity.name}.${fn.name} with`, res); + return res; + }, + (err) => { + console.log(`Ladda: Rejected ${entity.name}.${fn.name} with`, err) + return Promise.reject(err); + } + ); + } + } + } +}; +``` + +### Using your plugin with Ladda + +We now need to instruct Ladda to use our plugin. Ladda's `build` +function takes an optional second argument, which allows us to specify a +list of plugins we want to use. + + +```javascript +import { build } from 'ladda'; +import { logger } from './logger'; + +const config = { /* your ladda configuration */ }; + +export default build(config, [ + logger() +]); +``` + +Mind that plugins are evaluated from left to right. Given a list of +plugins like `[a(), b(), c()]` this means that plugin `c` would be able +to see all information the plugins `a` and `b` have provided. The +ApiFunction which is passed to `c` is the ApiFunction produced by `b`, +which itself is passed a reference to the ApiFunction produced by `a`. + +
+ +And that's it! Congratulations, you just built your first Ladda plugin! +You can try to run this code for yourself to see it in action, or open +up your developer console while browsing the [Contact List Example +Application](https://...). + +This app uses a more comprehensive implementation of the logger we just +built, which can be found in the +[ladda-logger](https://github.com/ladda-js/ladda-logger) repository. From 4b310307ede8aae01d86a2be1e77bdbdb1a3bd78 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 11:27:02 +0200 Subject: [PATCH 119/136] Finish first draft of plugin api docs --- docs/README.md | 1 + docs/advanced/Plugins-CheatSheet.md | 12 +- docs/advanced/Plugins.md | 228 ++++++++++++++++++++++++++-- 3 files changed, 222 insertions(+), 19 deletions(-) diff --git a/docs/README.md b/docs/README.md index 0b9e6b5..1e82115 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ * [Invalidation](/docs/advanced/Invalidation.md) * [Views](/docs/advanced/ViewOf.md) * [Custom ID](/docs/advanced/CustomId.md) + * [Change listener](/docs/advanced/ChangeListener.md) * [Plugins](/docs/advanced/Plugins.md) * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) * [Known Plugins](/docs/advanced/Plugins-KnownPlugins.md) diff --git a/docs/advanced/Plugins-CheatSheet.md b/docs/advanced/Plugins-CheatSheet.md index d1ec990..5b9aabb 100644 --- a/docs/advanced/Plugins-CheatSheet.md +++ b/docs/advanced/Plugins-CheatSheet.md @@ -3,7 +3,7 @@ ## Signature ```javascript -({ entityConfigs, config, addListener }) => ({ entity, fn }) => ApiFn +({ entityConfigs, config, addChangeListener }) => ({ entity, fn }) => ApiFn ```
@@ -12,8 +12,8 @@ It is a good practice to allow your users to pass in an additional plugin configuration object, thus reaching a final shape like this: ```javascript -export const yourPlugin = (pluginConfig) => { - return ({ entityConfigs, config, addListener }) => { +export const yourPlugin = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { // Use this space to setup additional data structures and helpers, // that act across entities. return ({ entity, fn }) => { @@ -23,9 +23,9 @@ export const yourPlugin = (pluginConfig) => { // Do your magic here! // Invoke the original fn with its arguments or a variation of it. return fn(...args); - } - } - } + }; + }; + }; }; ``` diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md index fa0254e..fea4b06 100644 --- a/docs/advanced/Plugins.md +++ b/docs/advanced/Plugins.md @@ -8,7 +8,7 @@ invalidation) is implemented using this API as well. Check out the [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) to get an overview on a single glance and take a look at our [curated list of -already built plugins](/docs/advanced/Plugins-KnownPlugins.md). +plugins](/docs/advanced/Plugins-KnownPlugins.md). ## Building a simple logger plugin @@ -21,25 +21,168 @@ Let's start with the minimal boilerplate, which is needed to get a plugin off the ground and then discuss each step in more detail. ```javascript -export const logger = (pluginConfig) => { - return ({ entityConfigs, config, addListener }) => { +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { return ({ entity, fn }) => { return (...args) => { return fn(...args); - } - } - } + }; + }; + }; +}; +``` + +### The plugin factory + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + // ... + }; +}; +``` + +It is generally a good practice to expose your plugin as a module, which +is a plugin factory: A higher order function which produces a plugin. + +While this is strictly speaking not needed it allows you to take +additional configuration arguments for your plugin. + +Our simple logger will not act on any additional configuration for now, +but it is not unreasonable to assume that we might enhance it's +capabilites in the future. We could for example create our plugin like +this: `logger({ disable: true })`, so that we could turn the logger off +with a simple boolean flag. + +Try to adhere to this principle, even if your plugin does not take any +configuration arguments when you start out. Also try to provide good +defaults, so that your users can try and play with your plugin easily. + +### Producing the plugin mapping function + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + return ({ entity, fn }) => { + // ... + }; + }; +}; +``` + +We mentioned earlier, that a plugin is a higher order function which +produces a mapping function, which should return a new ApiFunction. + +This function is called exactly once during build time (when Ladda's +`build` function is called). + +The Plugin API tries to give you as much information as possible while +you are creating your plugin. The plugin higher order function therefore +receives the complete entity configuration you specified, the global +ladda configuration and the registration function to add a change +listener. + +`entityConfigs` is a slightly enhanced version of the configuration you +defined as first argument of your `build` call. It is a dictionary, +where the keys are __EntityNames__ and the values __EntityConfigs__. + +There are three differences to what you initially passed: +- All defaults are applied, so that you can inspect precisely how each + entity is configured. +- For ease of use each __EntityConfig__ has an additional `name` + property, which equals to the __EntityName__. +- If you specified a global Ladda configuration with `__config`, you + will not find it here. + +`config` is the global Ladda Configuration you might have specified in +the `__config` field of your build configuration. Even if you left it +out (as it is optional) you will receive an object with applied defaults +here. + +`addChangeListener` allows us to register a callback to be invoked each +time something changes inside of Ladda's cache. Check the [Change +Listener documentation](/docs/advanced/ChangeListener.md) for more info. + +A more sophisticated plugin would use this space to define additional +data structures, that should act across all entities. + +Things are a little simpler with our logger plugin. Let's notify +the user that Ladda's setup is running and present all configuration we +received: + + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); + return ({ entity, fn }) => { + // ... + }; + }; +}; +``` + +We can also notify the users about any changes that happen within Ladda +and register a change listener: + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); + + addChangeListener((change) => console.log('Ladda: Cache change', change)); + + return ({ entity, fn }) => { + // ... + }; + }; }; ``` -This is the final version of our simple logger plugin: +We need to return a mapping function here, which will be invoked for +every ApiFunction we defined in our build configuration. Our goal is to +wrap such an ApiFunction and return one with enhanced functionality. + + +### Wrapping the original ApiFunction ```javascript -export const logger = (pluginConfig) => { - return ({ entityConfigs, config, addListener }) => { - console.log('Ladda: Setup in progress', entityConfigs, config); +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + // ... + + return ({ entity, fn }) => { + return (...args) => { + return fn(...args); + } + }; + }; +}; +``` + +Our mapping function will receive a single argument, which is an object +with two fields: + +- `entity` is an __EntityConfig__ as described above. All defaults are + applied and an additional `name` property is present to identify it. +- `fn` is the original __ApiFunction__ we want to act on. It has all + meta data attached, that was defined in the build configuration, +including defaults. - addListener((change) => console.log('Ladda: Cache change', change)); +With this comprehensive information we can easily add additional +behavior to an ApiFunction. + +We return a function which takes the same arguments as the original call +and make sure that we also return the same type. This is again fairly +simple in our logger example, where we can just invoke the original +function with the arguments we receive and return its value. + +Let's add some logging around this ApiFunction: + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + // ... return ({ entity, fn }) => { return (...args) => { @@ -55,11 +198,70 @@ export const logger = (pluginConfig) => { } ); } - } - } + }; + }; +}; +``` + +We issue a first log statement immediately when the function is invoked +and print out the arguments we received. By using the entity +configuration we got passed in and the meta data of the ApiFunction we +can produce a nice string to reveal which function just got called: +`${entity.name}.${fn.name}`. This could for example produce +something like `user.getAll`. + +We then use Promise chaining to intercept the result of our original +ApiFunction call and log whether the promise was resolved or rejected. +As our logger is a passive plugin that just provides an additional +side-effect (printing to the console), we make sure that we pass the +original results on properly: The resolved promise value, or the error +with which our promise got rejected. + +Mind that you can just return a plain function from this mapping +function. You do __NOT__ need to worry about all meta data the `fn` you +received was provided with. Ladda's `build` function will make sure, +that all meta data that was originally defined will be added to the +final API function. This includes additional meta data you define on the +ApiFunction object in your plugin (an example of this can be found in the +[ladda-observable](https://github.com/ladda-js/ladda-observable) plugin, which +adds an additional function to the ApiFunction object). + + +### Putting it altogether + +Here is the final version of our simple logger plugin: + +```javascript +export const logger = (pluginConfig = {}) => { + return ({ entityConfigs, config, addChangeListener }) => { + console.log('Ladda: Setup in progress', pluginConfig, entityConfigs, config); + + addChangeListener((change) => console.log('Ladda: Cache change', change)); + + return ({ entity, fn }) => { + return (...args) => { + console.log(`Ladda: Calling ${entity.name}.${fn.name} with args`, args); + return fn(...args).then( + (res) => { + console.log(`Ladda: Resolved ${entity.name}.${fn.name} with`, res); + return res; + }, + (err) => { + console.log(`Ladda: Rejected ${entity.name}.${fn.name} with`, err) + return Promise.reject(err); + } + ); + }; + }; + }; }; ``` +We log during the setup process and reveal all the configuration our +plugin would have access to, log all change objects which are spawned +when Ladda's cache is updated and inform our users about each individual +api call that is made. + ### Using your plugin with Ladda We now need to instruct Ladda to use our plugin. Ladda's `build` From 10c645775f17574b4f053ddc9f65ed28a9421f54 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 13:52:41 +0200 Subject: [PATCH 120/136] Simplify wording - make steps of create, setup and decorate more clear --- docs/advanced/Plugins-CheatSheet.md | 3 ++ docs/advanced/Plugins.md | 52 ++++++++++++++++++++--------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/advanced/Plugins-CheatSheet.md b/docs/advanced/Plugins-CheatSheet.md index 5b9aabb..bf03c07 100644 --- a/docs/advanced/Plugins-CheatSheet.md +++ b/docs/advanced/Plugins-CheatSheet.md @@ -29,6 +29,9 @@ export const yourPlugin = (pluginConfig = {}) => { }; ``` +We commonly refer to this process as __create => setup => decorate__ +steps, with the final goal of producing a decorated __ApiFunction__. + ## Apply a plugin Pass plugins in a list of plugins as an optional second argument to diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md index fea4b06..8cb31b8 100644 --- a/docs/advanced/Plugins.md +++ b/docs/advanced/Plugins.md @@ -12,13 +12,13 @@ plugins](/docs/advanced/Plugins-KnownPlugins.md). ## Building a simple logger plugin -At its core a plugin is a higher order function, which returns a mapping -function, which is invoked for each __ApiFunction__ you specified in -your Ladda configuration - it is is supposed to return a -new enhanced version of the given ApiFunction. +At its core a plugin is a higher order function, which returns a +decorator function, which is invoked for each __ApiFunction__ you +specified in your Ladda configuration - it is is supposed to return a +new decorated version of the given ApiFunction. Let's start with the minimal boilerplate, which is needed to get a -plugin off the ground and then discuss each step in more detail. +plugin off the ground: ```javascript export const logger = (pluginConfig = {}) => { @@ -32,7 +32,26 @@ export const logger = (pluginConfig = {}) => { }; ``` -### The plugin factory +These function can be described as three individual steps, which +eventually return a decorate ApiFunction. +We refer to these steps as __create__, __setup__ and __decorate__. + +If we were to give these functions names, our boilerplate would look +like this: + +```javascript +function create(pluginConfig = {}) { + return function setup({ entityConfigs, config, addChangeListener }) { + return function decorate({ entity, fn }) { + return function decoratedApiFn(...args) { + return fn(...args); + } + } + } +} +``` + +### Create: The plugin factory ```javascript export const logger = (pluginConfig = {}) => { @@ -43,7 +62,7 @@ export const logger = (pluginConfig = {}) => { ``` It is generally a good practice to expose your plugin as a module, which -is a plugin factory: A higher order function which produces a plugin. +is a plugin factory: A function which produces a plugin. While this is strictly speaking not needed it allows you to take additional configuration arguments for your plugin. @@ -58,7 +77,7 @@ Try to adhere to this principle, even if your plugin does not take any configuration arguments when you start out. Also try to provide good defaults, so that your users can try and play with your plugin easily. -### Producing the plugin mapping function +### Setup: Producing the plugin decorator function ```javascript export const logger = (pluginConfig = {}) => { @@ -70,14 +89,14 @@ export const logger = (pluginConfig = {}) => { }; ``` -We mentioned earlier, that a plugin is a higher order function which -produces a mapping function, which should return a new ApiFunction. +We mentioned earlier, that a plugin is a function which produces a +decorator function, which should return a new decorated ApiFunction. This function is called exactly once during build time (when Ladda's `build` function is called). The Plugin API tries to give you as much information as possible while -you are creating your plugin. The plugin higher order function therefore +you are creating your plugin. The plugin function therefore receives the complete entity configuration you specified, the global ladda configuration and the registration function to add a change listener. @@ -104,7 +123,8 @@ time something changes inside of Ladda's cache. Check the [Change Listener documentation](/docs/advanced/ChangeListener.md) for more info. A more sophisticated plugin would use this space to define additional -data structures, that should act across all entities. +data structures, that should act across all entities, hence we refer to +this step as __setup__. Things are a little simpler with our logger plugin. Let's notify the user that Ladda's setup is running and present all configuration we @@ -139,12 +159,12 @@ export const logger = (pluginConfig = {}) => { }; ``` -We need to return a mapping function here, which will be invoked for +We need to return a decorator function here, which will be invoked for every ApiFunction we defined in our build configuration. Our goal is to wrap such an ApiFunction and return one with enhanced functionality. -### Wrapping the original ApiFunction +### Decorate: Wrapping the original ApiFunction ```javascript export const logger = (pluginConfig = {}) => { @@ -160,7 +180,7 @@ export const logger = (pluginConfig = {}) => { }; ``` -Our mapping function will receive a single argument, which is an object +Our decorator function will receive a single argument, which is an object with two fields: - `entity` is an __EntityConfig__ as described above. All defaults are @@ -217,7 +237,7 @@ side-effect (printing to the console), we make sure that we pass the original results on properly: The resolved promise value, or the error with which our promise got rejected. -Mind that you can just return a plain function from this mapping +Mind that you can just return a plain function from this decorator function. You do __NOT__ need to worry about all meta data the `fn` you received was provided with. Ladda's `build` function will make sure, that all meta data that was originally defined will be added to the From 147ca5aa82a1b9d7bac1555d66d7a7f1b858d22c Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 13:56:48 +0200 Subject: [PATCH 121/136] Rename addListener to addChangeListener --- package.json | 2 +- src/builder.js | 11 ++++++----- src/builder.spec.js | 10 +++++----- src/listener-store.js | 4 ++-- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index ef8d39e..20e530c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ladda-cache", - "version": "0.2.0", + "version": "0.2.1", "description": "Data fetching layer with support for caching", "main": "dist/bundle.js", "dependencies": { diff --git a/src/builder.js b/src/builder.js index 8ad919f..4bc6de2 100644 --- a/src/builder.js +++ b/src/builder.js @@ -113,8 +113,8 @@ const getEntityConfigs = compose( filterObject(compose(not, isEqual('__config'))) ); -const applyPlugin = curry((addListener, config, entityConfigs, plugin) => { - const pluginDecorator = plugin({ addListener, config, entityConfigs }); +const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => { + const pluginDecorator = plugin({ addChangeListener, config, entityConfigs }); return mapApiFunctions(pluginDecorator, entityConfigs); }); @@ -122,8 +122,9 @@ const applyPlugin = curry((addListener, config, entityConfigs, plugin) => { export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const listenerStore = createListenerStore(config); - const addListener = set(['__addListener'], listenerStore.addListener); - const applyPlugins = reduce(applyPlugin(listenerStore.addListener, config), getEntityConfigs(c)); - const createApi = compose(addListener, toApi, applyPlugins); + const addChangeListener = set(['__addChangeListener'], listenerStore.addChangeListener); + const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config); + const applyPlugins = reduce(applyPlugin_, getEntityConfigs(c)); + const createApi = compose(addChangeListener, toApi, applyPlugins); return createApi([decorator(listenerStore.onChange), ...ps, dedup]); }; diff --git a/src/builder.spec.js b/src/builder.spec.js index 2472c82..f2227ea 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -151,14 +151,14 @@ describe('builder', () => { it('exposes Ladda\'s listener/onChange interface', () => { const api = build(config()); - expect(api.__addListener).to.be; + expect(api.__addChangeListener).to.be; }); - describe('__addListener', () => { + describe('__addChangeListener', () => { it('allows to add a listener, which gets notified on all cache changes', () => { const api = build(config()); const spy = sinon.spy(); - api.__addListener(spy); + api.__addChangeListener(spy); return api.user.getUsers().then(() => { expect(spy).to.have.been.calledOnce; @@ -172,7 +172,7 @@ describe('builder', () => { it('does not trigger when a pure cache hit is made', () => { const api = build(config()); const spy = sinon.spy(); - api.__addListener(spy); + api.__addChangeListener(spy); return api.user.getUsers().then(() => { expect(spy).to.have.been.calledOnce; @@ -186,7 +186,7 @@ describe('builder', () => { it('returns a deregistration function to remove the listener', () => { const api = build(config()); const spy = sinon.spy(); - const deregister = api.__addListener(spy); + const deregister = api.__addChangeListener(spy); deregister(); return api.user.getUsers().then(() => { diff --git a/src/listener-store.js b/src/listener-store.js index 1b982e3..5902b3e 100644 --- a/src/listener-store.js +++ b/src/listener-store.js @@ -6,7 +6,7 @@ const remove = curry((el, arr) => { return arr; }); -const addListener = curry((listeners, listener) => { +const addChangeListener = curry((listeners, listener) => { listeners.push(listener); return () => remove(listener, listeners); }); @@ -17,7 +17,7 @@ export const createListenerStore = () => { const listeners = []; return { onChange: notify(listeners), - addListener: addListener(listeners) + addChangeListener: addChangeListener(listeners) }; }; From 72c8f8438d0aaa8460396f1dca3ab1f9de380c8e Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 17:59:30 +0200 Subject: [PATCH 122/136] Minor doc updates --- docs/advanced/Plugins.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md index 8cb31b8..2a05f9c 100644 --- a/docs/advanced/Plugins.md +++ b/docs/advanced/Plugins.md @@ -96,9 +96,9 @@ This function is called exactly once during build time (when Ladda's `build` function is called). The Plugin API tries to give you as much information as possible while -you are creating your plugin. The plugin function therefore -receives the complete entity configuration you specified, the global -ladda configuration and the registration function to add a change +you are creating your plugin. The plugin function therefore receives a +single object with the complete entity configuration you specified, the +global ladda configuration and the registration function to add a change listener. `entityConfigs` is a slightly enhanced version of the configuration you @@ -126,9 +126,9 @@ A more sophisticated plugin would use this space to define additional data structures, that should act across all entities, hence we refer to this step as __setup__. -Things are a little simpler with our logger plugin. Let's notify -the user that Ladda's setup is running and present all configuration we -received: +Things are a little simpler with our logger plugin - e.g. it doesn't +hold any state of its own. Let's notify the user that Ladda's setup is +running and present all configuration we received: ```javascript @@ -311,8 +311,6 @@ which itself is passed a reference to the ApiFunction produced by `a`. And that's it! Congratulations, you just built your first Ladda plugin! You can try to run this code for yourself to see it in action, or open up your developer console while browsing the [Contact List Example -Application](https://...). - -This app uses a more comprehensive implementation of the logger we just -built, which can be found in the +Application](https://...). This app uses a more comprehensive +implementation of the logger we just built, which can be found in the [ladda-logger](https://github.com/ladda-js/ladda-logger) repository. From 8584f0bd977e0d7188ecef07482818cc018f2265 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 20:56:48 +0200 Subject: [PATCH 123/136] Add change listener readme, possibly to be removed again --- docs/README.md | 2 +- docs/advanced/ChangeListener.md | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 docs/advanced/ChangeListener.md diff --git a/docs/README.md b/docs/README.md index 1e82115..819799e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,7 @@ * [Invalidation](/docs/advanced/Invalidation.md) * [Views](/docs/advanced/ViewOf.md) * [Custom ID](/docs/advanced/CustomId.md) - * [Change listener](/docs/advanced/ChangeListener.md) + * [Change Listener](/docs/advanced/ChangeListener.md) * [Plugins](/docs/advanced/Plugins.md) * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) * [Known Plugins](/docs/advanced/Plugins-KnownPlugins.md) diff --git a/docs/advanced/ChangeListener.md b/docs/advanced/ChangeListener.md new file mode 100644 index 0000000..faf50a5 --- /dev/null +++ b/docs/advanced/ChangeListener.md @@ -0,0 +1,37 @@ +# Change Listener + +The returned api object of Ladda's `build` function exposes a registration function to be notified every time entities inside Ladda's cache change. The field is called `__addChangeListener`. + +```javascript +import { build } from 'ladda-cache'; + +const config = { /* your configuration here */ }; +const api = build(config); + +api.__addChangeListener((change) => /* act on change */) +``` + +`__addChangeListener` returns an unsubscribe function to stop listening +for changes. + +```javascript +const unsubscribe = api.__addChangeListener((change) => /* act on change */) +unsubscribe(); +``` + +The signature of the change object is as follows: +```javascript +{ + type: 'UPDATE' | 'REMOVE', + entity: EntityName, + entities: EntityValue[] +} +``` + +At this point in time there is no difference made between adding new +EntityValues and updating already present ones: Both events lead to a +change of the type `UPDATE`. +The `entities` field is guaranteed to be a list of EntityValues, even if +a change only affects a single entity. + + From 90efee4b7061b14e03119c13d72f5a2cbc5dd72f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Sun, 23 Apr 2017 21:38:31 +0200 Subject: [PATCH 124/136] Document byIds --- docs/basics/Configuration.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/basics/Configuration.md b/docs/basics/Configuration.md index dba06ef..0382434 100644 --- a/docs/basics/Configuration.md +++ b/docs/basics/Configuration.md @@ -24,6 +24,11 @@ There are a handful optional options to configure Ladda. In a minimal configurat * **byId**: `true | false`. This is an optimization that tells Ladda that the first argument is an id. This allows Ladda to directly try to fetch the data from the cache, even if it was acquired by another call. This is useful if you previously called for example "getAllUsers" and now want to fetch one user directly from the cache. By default false. +* **byIds**: `true | false`. Another optimization which tells Ladda that + the first argument is a list of ids. Ladda will try to make an optimal +call (if any), looking up items from the cache and only calling for +items that are not yet present. Defaults to false. + ## Ladda Configuration * **idField**: Specify the default property that contains the ID. By default this is `"id"`. From 565d27b0adca1b770413519b4bd6c9ad43381d91 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 19:04:01 +0200 Subject: [PATCH 125/136] Hide addChangeListener - it is accessible for plugins ONLY now --- src/builder.js | 6 ++---- src/builder.spec.js | 45 ++++++++++++++++++++++++++++++++------------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/builder.js b/src/builder.js index 4bc6de2..640f891 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,6 +1,5 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, - prop, filterObject, isEqual, not, curry, copyFunction, - set + prop, filterObject, isEqual, not, curry, copyFunction } from 'ladda-fp'; import {decorator} from './decorator'; @@ -122,9 +121,8 @@ const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; const listenerStore = createListenerStore(config); - const addChangeListener = set(['__addChangeListener'], listenerStore.addChangeListener); const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config); const applyPlugins = reduce(applyPlugin_, getEntityConfigs(c)); - const createApi = compose(addChangeListener, toApi, applyPlugins); + const createApi = compose(toApi, applyPlugins); return createApi([decorator(listenerStore.onChange), ...ps, dedup]); }; diff --git a/src/builder.spec.js b/src/builder.spec.js index f2227ea..af81d15 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -149,16 +149,25 @@ describe('builder', () => { .then(() => done()); }); - it('exposes Ladda\'s listener/onChange interface', () => { - const api = build(config()); - expect(api.__addChangeListener).to.be; - }); + describe('change listener', () => { + it('exposes Ladda\'s listener/onChange interface to plugins', () => { + const plugin = ({ addChangeListener }) => { + expect(addChangeListener).to.be; + return ({ fn }) => fn; + }; + + build(config(), [plugin]); + }); - describe('__addChangeListener', () => { - it('allows to add a listener, which gets notified on all cache changes', () => { - const api = build(config()); + it('allows plugins to add a listener, which gets notified on all cache changes', () => { const spy = sinon.spy(); - api.__addChangeListener(spy); + + const plugin = ({ addChangeListener }) => { + addChangeListener(spy); + return ({ fn }) => fn; + }; + + const api = build(config(), [plugin]); return api.user.getUsers().then(() => { expect(spy).to.have.been.calledOnce; @@ -170,9 +179,14 @@ describe('builder', () => { }); it('does not trigger when a pure cache hit is made', () => { - const api = build(config()); const spy = sinon.spy(); - api.__addChangeListener(spy); + + const plugin = ({ addChangeListener }) => { + addChangeListener(spy); + return ({ fn }) => fn; + }; + + const api = build(config(), [plugin]); return api.user.getUsers().then(() => { expect(spy).to.have.been.calledOnce; @@ -184,10 +198,15 @@ describe('builder', () => { }); it('returns a deregistration function to remove the listener', () => { - const api = build(config()); const spy = sinon.spy(); - const deregister = api.__addChangeListener(spy); - deregister(); + + const plugin = ({ addChangeListener }) => { + const deregister = addChangeListener(spy); + deregister(); + return ({ fn }) => fn; + }; + + const api = build(config(), [plugin]); return api.user.getUsers().then(() => { expect(spy).not.to.have.been.called; From cbe35a3709fa3a5a7cec3e653b355cda2ff3dafe Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 19:08:59 +0200 Subject: [PATCH 126/136] Document addChangeListener within the plugin API --- docs/README.md | 1 - docs/advanced/Plugins.md | 26 ++++++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 819799e..0b9e6b5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,6 @@ * [Invalidation](/docs/advanced/Invalidation.md) * [Views](/docs/advanced/ViewOf.md) * [Custom ID](/docs/advanced/CustomId.md) - * [Change Listener](/docs/advanced/ChangeListener.md) * [Plugins](/docs/advanced/Plugins.md) * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) * [Known Plugins](/docs/advanced/Plugins-KnownPlugins.md) diff --git a/docs/advanced/Plugins.md b/docs/advanced/Plugins.md index 2a05f9c..7af4223 100644 --- a/docs/advanced/Plugins.md +++ b/docs/advanced/Plugins.md @@ -119,8 +119,30 @@ out (as it is optional) you will receive an object with applied defaults here. `addChangeListener` allows us to register a callback to be invoked each -time something changes inside of Ladda's cache. Check the [Change -Listener documentation](/docs/advanced/ChangeListener.md) for more info. +time something changes inside of Ladda's cache. +The callback is invoked with a single argument, a ChangeObject of the +following shape: + + +```javascript +{ + type: 'UPDATE' | 'REMOVE', + entity: EntityName, + entities: EntityValue[] +} +``` + +At this point in time there is no difference made between adding new +EntityValues and updating already present ones: Both events lead to a +change of the type `UPDATE`. +The `entities` field is guaranteed to be a list of EntityValues, even if +a change only affects a single entity. + +`addChangeListener` returns a deregistration function. Call it to stop +listening for changes. + +
+ A more sophisticated plugin would use this space to define additional data structures, that should act across all entities, hence we refer to From c709e1999805b470df799d668c48b7744c41b2c3 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 08:46:59 +0200 Subject: [PATCH 127/136] Start validation of entity configs --- src/builder.js | 9 ++-- src/validator.js | 68 +++++++++++++++++++++++++ src/validator.spec.js | 116 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/validator.js create mode 100644 src/validator.spec.js diff --git a/src/builder.js b/src/builder.js index 640f891..34a4f3c 100644 --- a/src/builder.js +++ b/src/builder.js @@ -5,6 +5,7 @@ import {mapObject, mapValues, compose, toObject, reduce, toPairs, import {decorator} from './decorator'; import {dedup} from './dedup'; import {createListenerStore} from './listener-store'; +import {validateConfig} from './validator'; // [[EntityName, EntityConfig]] -> Entity const toEntity = ([name, c]) => ({ @@ -99,12 +100,12 @@ const setApiConfigDefaults = ec => { return { ...ec, - api: mapValues(setDefaults, ec.api) + api: ec.api ? mapValues(setDefaults, ec.api) : ec.api }; }; // Config -> Map String EntityConfig -const getEntityConfigs = compose( +export const getEntityConfigs = compose( // exported for testing toObject(prop('name')), mapObject(toEntity), mapValues(setApiConfigDefaults), @@ -120,9 +121,11 @@ const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => // Config -> Api export const build = (c, ps = []) => { const config = c.__config || {idField: 'id'}; + const entityConfigs = getEntityConfigs(c); + validateConfig(console, entityConfigs, config); const listenerStore = createListenerStore(config); const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config); - const applyPlugins = reduce(applyPlugin_, getEntityConfigs(c)); + const applyPlugins = reduce(applyPlugin_, entityConfigs); const createApi = compose(toApi, applyPlugins); return createApi([decorator(listenerStore.onChange), ...ps, dedup]); }; diff --git a/src/validator.js b/src/validator.js new file mode 100644 index 0000000..a82669f --- /dev/null +++ b/src/validator.js @@ -0,0 +1,68 @@ +/* eslint-disable max-len */ +import { compose, map_, toPairs } from 'ladda-fp'; + +const warn = (logger, msg, ...args) => { + logger.error(`Ladda Config Error: ${msg}`, ...args); +}; + +const OPERATIONS = ['CREATE', 'READ', 'UPDATE', 'DELETE', 'NO_OPERATION']; +const isOperation = (op) => OPERATIONS.indexOf(op) !== -1; +const isConfigured = (entityName, entityConfigs) => { + return !!entityConfigs[entityName]; +}; + +const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); +const operationsAsString = () => OPERATIONS.join(', '); + + +const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) => { + if (typeof entity.api !== 'object') { + warn(logger, `No api definition found for entity ${entityName}`); + } +}; + +const checkViewOf = (logger, entityConfigs, config, entityName, entity) => { + const { viewOf } = entity; + if (viewOf && !isConfigured(viewOf, entityConfigs)) { + warn(logger, `The view ${viewOf} of entity ${entityName} is not configured. Use on of: `, getEntityNames(entityConfigs)); + } +}; + +const checkInvalidations = (logger, entityConfigs, config, entityName, entity) => { + const { invalidates, invalidatesOn } = entity; + map_((entityToInvalidate) => { + if (!isConfigured(entityToInvalidate, entityConfigs)) { + warn(logger, `Entity ${entityName} tries to invalidate ${entityToInvalidate}, which is not configured. Use one of: `, getEntityNames(entityConfigs)); + } + }, invalidates); + + map_((operation) => { + if (!isOperation(operation)) { + warn(logger, `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: ${operationsAsString()}`); + } + }, invalidatesOn); +}; + +const checkEntities = (logger, entityConfigs, config) => { + const checks = [ + checkApiDeclaration, + checkViewOf, + checkInvalidations + ]; + compose( + map_(([entityName, entity]) => { + map_((check) => check(logger, entityConfigs, config, entityName, entity), checks); + }), + toPairs + )(entityConfigs); +}; + + +export const validateConfig = (logger, entityConfigs, config) => { + const isProduction = process.NODE_ENV === 'production' || config.useProductionBuild; + if (isProduction) { + return; + } + + checkEntities(logger, entityConfigs, config); +}; diff --git a/src/validator.spec.js b/src/validator.spec.js new file mode 100644 index 0000000..a294216 --- /dev/null +++ b/src/validator.spec.js @@ -0,0 +1,116 @@ +/* eslint-disable no-unused-expressions */ +import sinon from 'sinon'; +import { validateConfig } from './validator'; +import { getEntityConfigs } from './builder'; + +const createLogger = () => ({ + error: sinon.spy() +}); + +describe('validateConfig', () => { + it('does not do anything when using production build', () => { + const logger = createLogger(); + const eConfigs = getEntityConfigs({ + user: {} + }); + const config = { useProductionBuild: true }; + + validateConfig(logger, eConfigs, config); + expect(logger.error).not.to.have.been.called; + }); + + it('checks for missing api declarations', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { + getAll: () => {} + } + }, + activity: {} + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/No api definition.*activity/); + }); + + it('checks for non-configured views', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { + getAll: () => {} + } + }, + mediumUser: { + api: { + getAll: () => {} + }, + viewOf: 'user' + }, + miniUser: { + api: { + getAll: () => {} + }, + viewOf: 'mdiumUser' // typo! + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/mdiumUser.*miniUser.*not configured/); + }); + + it('checks for wrong invalidation targets', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll: () => {} }, + invalidates: ['activity', 'ntification'] // typo! + }, + activity: { + api: { getAll: () => {} }, + viewOf: 'user' + }, + notification: { + api: { getAll: () => {} }, + invalidates: ['activity'] + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.*invalidate.*ntification.*not configured/); + }); + + it('checks for wrong invalidation operations', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll: () => {} } + }, + activity: { + api: { getAll: () => {} }, + viewOf: 'user' + }, + notification: { + api: { getAll: () => {} }, + invalidates: ['activity'], + invalidatesOn: ['X'] + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/notification.*invalid operation.*X/); + }); +}); From 8707d2ed9cbb29fcee734aad1158ef2ca74098bc Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 08:52:03 +0200 Subject: [PATCH 128/136] Better config defaults and don't validate in builder specs --- src/builder.js | 8 +++++++- src/builder.spec.js | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/builder.js b/src/builder.js index 34a4f3c..c660985 100644 --- a/src/builder.js +++ b/src/builder.js @@ -113,6 +113,12 @@ export const getEntityConfigs = compose( // exported for testing filterObject(compose(not, isEqual('__config'))) ); +const getGlobalConfig = (config) => ({ + idField: 'id', + useProductionBuild: process.NODE_ENV === 'production', + ...(config.__config || {}) +}); + const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => { const pluginDecorator = plugin({ addChangeListener, config, entityConfigs }); return mapApiFunctions(pluginDecorator, entityConfigs); @@ -120,7 +126,7 @@ const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) => // Config -> Api export const build = (c, ps = []) => { - const config = c.__config || {idField: 'id'}; + const config = getGlobalConfig(c); const entityConfigs = getEntityConfigs(c); validateConfig(console, entityConfigs, config); const listenerStore = createListenerStore(config); diff --git a/src/builder.spec.js b/src/builder.spec.js index af81d15..8ef8a3e 100644 --- a/src/builder.spec.js +++ b/src/builder.spec.js @@ -19,6 +19,9 @@ const config = () => ({ deleteUser }, invalidates: ['alles'] + }, + __config: { + useProductionBuild: true } }); @@ -76,7 +79,7 @@ describe('builder', () => { }); it('Works with non default id set', (done) => { const myConfig = config(); - myConfig.__config = {idField: 'mySecretId'}; + myConfig.__config = {idField: 'mySecretId', useProductionBuild: true}; myConfig.user.api.getUsers = sinon.spy( () => Promise.resolve([{mySecretId: 1}, {mySecretId: 2}]) ); From 00a64c1a5d3c11109e1a87b709359a7d2b6d5354 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 10:06:40 +0200 Subject: [PATCH 129/136] Check api declarations in more detail --- src/validator.js | 56 +++++++++++++++-- src/validator.spec.js | 139 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 189 insertions(+), 6 deletions(-) diff --git a/src/validator.js b/src/validator.js index a82669f..5ca4039 100644 --- a/src/validator.js +++ b/src/validator.js @@ -12,13 +12,49 @@ const isConfigured = (entityName, entityConfigs) => { }; const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); -const operationsAsString = () => OPERATIONS.join(', '); - const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) => { if (typeof entity.api !== 'object') { warn(logger, `No api definition found for entity ${entityName}`); + return; } + const warnApi = (msg, ...args) => { + return warn(logger, `Invalid api config. ${msg}`, ...args); + }; + + const apiNames = Object.keys(entity.api); + + compose( + // eslint-disable-next-line no-unused-vars + map_(([fnName, fn]) => { + const { operation, invalidates, idFrom, byId, byIds } = fn; + const fullName = `${entityName}.${fnName}`; + if (!isOperation(operation)) { + warnApi(`${fullName}'s operation is ${operation}, use one of: `, OPERATIONS); + } + + if (typeof byId !== 'boolean') { + warnApi(`${fullName}'s byId needs to be a boolean, was ${typeof byId}'`); + } + + if (typeof byIds !== 'boolean') { + warnApi(`${fullName}'s byIds needs to be a boolean, was ${typeof byIds}'`); + } + + if (typeof idFrom !== 'function') { + if (typeof idFrom !== 'string' || ['ENTITY', 'ARGS'].indexOf(idFrom) === -1) { + warnApi(`${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)`); + } + } + + map_((fnToInvalidate) => { + if (typeof entity.api[fnToInvalidate] !== 'function') { + warnApi(`${fullName} tries to invalidate ${fnToInvalidate}, which is not a function. Use on of: `, apiNames); + } + }, invalidates); + }), + toPairs + )(entity.api); }; const checkViewOf = (logger, entityConfigs, config, entityName, entity) => { @@ -38,16 +74,23 @@ const checkInvalidations = (logger, entityConfigs, config, entityName, entity) = map_((operation) => { if (!isOperation(operation)) { - warn(logger, `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: ${operationsAsString()}`); + warn(logger, `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: `, OPERATIONS); } }, invalidatesOn); }; +const checkTTL = (logger, entityConfigs, config, entityName, entity) => { + if (typeof entity.ttl !== 'number') { + warn(logger, `Entity ${entityName} specified ttl as type of ${typeof entity.ttl}, needs to be a number in seconds`); + } +}; + const checkEntities = (logger, entityConfigs, config) => { const checks = [ checkApiDeclaration, checkViewOf, - checkInvalidations + checkInvalidations, + checkTTL ]; compose( map_(([entityName, entity]) => { @@ -59,8 +102,9 @@ const checkEntities = (logger, entityConfigs, config) => { export const validateConfig = (logger, entityConfigs, config) => { - const isProduction = process.NODE_ENV === 'production' || config.useProductionBuild; - if (isProduction) { + // do not remove the process.NODE_ENV check here - allows uglifiers + // to optimize and remove all unreachable code. + if (process.NODE_ENV === 'production' || config.useProductionBuild) { return; } diff --git a/src/validator.spec.js b/src/validator.spec.js index a294216..b4f07ef 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -113,4 +113,143 @@ describe('validateConfig', () => { expect(logger.error).to.have.been.called; expect(logger.error.args[0][0]).to.match(/notification.*invalid operation.*X/); }); + + it('checks for wrong ttl values', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll: () => {} }, + ttl: 300 + }, + activity: { + api: { getAll: () => {} }, + ttl: 'xxx' + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/activity.*ttl.*string.*needs to be a number/); + }); + + it('checks for wrong api operations', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'X'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*operation.*X/); + }); + + it('checks for wrong api byId field', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.byId = 'xxx'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*byId.*string/); + }); + + it('checks for wrong api byIds field', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.byIds = 'xxx'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*byIds.*string/); + }); + + it('checks for wrong api idFrom (illegal type)', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.idFrom = true; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*idFrom/); + }); + + it('checks for wrong api idFrom (illegal string)', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.idFrom = 'X'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*idFrom/); + }); + + it('checks for wrong api invalidates definition', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.invalidates = ['getOne', 'getSme']; // typo! + + const getOne = () => {}; + getOne.operation = 'READ'; + + const getSome = () => {}; + getSome.operation = 'READ'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll, getSome, getOne } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*invalidate.*getSme/); + }); }); From ab76d298deaf25bec5f6501990be829ab0321d35 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 10:08:47 +0200 Subject: [PATCH 130/136] Add sanity check specs --- src/validator.spec.js | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/validator.spec.js b/src/validator.spec.js index b4f07ef..60b03fa 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -252,4 +252,46 @@ describe('validateConfig', () => { expect(logger.error).to.have.been.called; expect(logger.error.args[0][0]).to.match(/user.getAll.*invalidate.*getSme/); }); + + it('informs about several errors', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.idFrom = 'X'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll }, + invalidates: ['X'] + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.calledTwice; + }); + + it('happily accepts valid configurations', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.idFrom = 'ENTITY'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll }, + invalidates: ['activity'] + }, + activity: { + api: { getAll: () => {} }, + ttl: 400 + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).not.to.have.been.calledTwice; + }); }); From e8b7f2458e5d92a964cab3920e1bb74f4f84f5c9 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 19:25:13 +0200 Subject: [PATCH 131/136] Minor reformatting in validator --- src/validator.js | 64 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/validator.js b/src/validator.js index 5ca4039..8fcfcda 100644 --- a/src/validator.js +++ b/src/validator.js @@ -7,9 +7,8 @@ const warn = (logger, msg, ...args) => { const OPERATIONS = ['CREATE', 'READ', 'UPDATE', 'DELETE', 'NO_OPERATION']; const isOperation = (op) => OPERATIONS.indexOf(op) !== -1; -const isConfigured = (entityName, entityConfigs) => { - return !!entityConfigs[entityName]; -}; +const isConfigured = (entityName, entityConfigs) => !!entityConfigs[entityName]; +const isIdFromString = (idFrom) => typeof idfrom === 'string' && ['ENTITY', 'ARGS'].indexOf(idFrom) !== -1; const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); @@ -18,9 +17,12 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) warn(logger, `No api definition found for entity ${entityName}`); return; } - const warnApi = (msg, ...args) => { - return warn(logger, `Invalid api config. ${msg}`, ...args); - }; + + const warnApi = (msg, ...args) => warn( + logger, + `Invalid api config. ${msg}`, + ...args + ); const apiNames = Object.keys(entity.api); @@ -30,26 +32,36 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) const { operation, invalidates, idFrom, byId, byIds } = fn; const fullName = `${entityName}.${fnName}`; if (!isOperation(operation)) { - warnApi(`${fullName}'s operation is ${operation}, use one of: `, OPERATIONS); + warnApi( + `${fullName}'s operation is ${operation}, use one of: `, + OPERATIONS + ); } if (typeof byId !== 'boolean') { - warnApi(`${fullName}'s byId needs to be a boolean, was ${typeof byId}'`); + warnApi( + `${fullName}'s byId needs to be a boolean, was ${typeof byId}'` + ); } if (typeof byIds !== 'boolean') { - warnApi(`${fullName}'s byIds needs to be a boolean, was ${typeof byIds}'`); + warnApi( + `${fullName}'s byIds needs to be a boolean, was ${typeof byIds}'` + ); } - if (typeof idFrom !== 'function') { - if (typeof idFrom !== 'string' || ['ENTITY', 'ARGS'].indexOf(idFrom) === -1) { - warnApi(`${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)`); - } + if (typeof idFrom !== 'function' || !isIdFromString(idFrom)) { + warnApi( + `${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)` + ); } map_((fnToInvalidate) => { if (typeof entity.api[fnToInvalidate] !== 'function') { - warnApi(`${fullName} tries to invalidate ${fnToInvalidate}, which is not a function. Use on of: `, apiNames); + warnApi( + `${fullName} tries to invalidate ${fnToInvalidate}, which is not a function. Use on of: `, + apiNames + ); } }, invalidates); }), @@ -60,7 +72,11 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) const checkViewOf = (logger, entityConfigs, config, entityName, entity) => { const { viewOf } = entity; if (viewOf && !isConfigured(viewOf, entityConfigs)) { - warn(logger, `The view ${viewOf} of entity ${entityName} is not configured. Use on of: `, getEntityNames(entityConfigs)); + warn( + logger, + `The view ${viewOf} of entity ${entityName} is not configured. Use on of: `, + getEntityNames(entityConfigs) + ); } }; @@ -68,20 +84,31 @@ const checkInvalidations = (logger, entityConfigs, config, entityName, entity) = const { invalidates, invalidatesOn } = entity; map_((entityToInvalidate) => { if (!isConfigured(entityToInvalidate, entityConfigs)) { - warn(logger, `Entity ${entityName} tries to invalidate ${entityToInvalidate}, which is not configured. Use one of: `, getEntityNames(entityConfigs)); + warn( + logger, + `Entity ${entityName} tries to invalidate ${entityToInvalidate}, which is not configured. Use one of: `, + getEntityNames(entityConfigs) + ); } }, invalidates); map_((operation) => { if (!isOperation(operation)) { - warn(logger, `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: `, OPERATIONS); + warn( + logger, + `Entity ${entityName} tries to invalidate on invalid operation ${operation}. Use on of: `, + OPERATIONS + ); } }, invalidatesOn); }; const checkTTL = (logger, entityConfigs, config, entityName, entity) => { if (typeof entity.ttl !== 'number') { - warn(logger, `Entity ${entityName} specified ttl as type of ${typeof entity.ttl}, needs to be a number in seconds`); + warn( + logger, + `Entity ${entityName} specified ttl as type of ${typeof entity.ttl}, needs to be a number in seconds` + ); } }; @@ -92,6 +119,7 @@ const checkEntities = (logger, entityConfigs, config) => { checkInvalidations, checkTTL ]; + compose( map_(([entityName, entity]) => { map_((check) => check(logger, entityConfigs, config, entityName, entity), checks); From cae68f9715f2ab4c4f9b2a7be274f6371db9b2ca Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 19:34:09 +0200 Subject: [PATCH 132/136] Minor fixes to validator --- src/validator.js | 7 ++++--- src/validator.spec.js | 14 +++++++++++++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/validator.js b/src/validator.js index 8fcfcda..46b3aa6 100644 --- a/src/validator.js +++ b/src/validator.js @@ -8,7 +8,8 @@ const warn = (logger, msg, ...args) => { const OPERATIONS = ['CREATE', 'READ', 'UPDATE', 'DELETE', 'NO_OPERATION']; const isOperation = (op) => OPERATIONS.indexOf(op) !== -1; const isConfigured = (entityName, entityConfigs) => !!entityConfigs[entityName]; -const isIdFromString = (idFrom) => typeof idfrom === 'string' && ['ENTITY', 'ARGS'].indexOf(idFrom) !== -1; +const isIdFromString = (idFrom) => typeof idFrom === 'string' && ['ENTITY', 'ARGS'].indexOf(idFrom) !== -1; +const isValidLogger = (logger) => logger && typeof logger.error === 'function'; const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); @@ -50,7 +51,7 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) ); } - if (typeof idFrom !== 'function' || !isIdFromString(idFrom)) { + if (typeof idFrom !== 'function' && !isIdFromString(idFrom)) { warnApi( `${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)` ); @@ -132,7 +133,7 @@ const checkEntities = (logger, entityConfigs, config) => { export const validateConfig = (logger, entityConfigs, config) => { // do not remove the process.NODE_ENV check here - allows uglifiers // to optimize and remove all unreachable code. - if (process.NODE_ENV === 'production' || config.useProductionBuild) { + if (process.NODE_ENV === 'production' || config.useProductionBuild || !isValidLogger(logger)) { return; } diff --git a/src/validator.spec.js b/src/validator.spec.js index 60b03fa..00bc416 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -19,6 +19,18 @@ describe('validateConfig', () => { expect(logger.error).not.to.have.been.called; }); + it('does not do anything when invalid logger is passed', () => { + const invalidLogger = { x: sinon.spy() }; + + const eConfigs = getEntityConfigs({ + user: {} + }); + const config = { useProductionBuild: true }; + + validateConfig(invalidLogger, eConfigs, config); + expect(invalidLogger.x).not.to.have.been.called; + }); + it('checks for missing api declarations', () => { const logger = createLogger(); @@ -292,6 +304,6 @@ describe('validateConfig', () => { const config = {}; validateConfig(logger, eConfigs, config); - expect(logger.error).not.to.have.been.calledTwice; + expect(logger.error).not.to.have.been.called; }); }); From b6d745238fbbdf1eded30cff3df52613175b3b2f Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 20:31:37 +0200 Subject: [PATCH 133/136] Document dedup --- docs/README.md | 1 + docs/advanced/Deduplication.md | 30 ++++++++++++++++++++++++++++++ docs/basics/Configuration.md | 9 +++++++++ 3 files changed, 40 insertions(+) create mode 100644 docs/advanced/Deduplication.md diff --git a/docs/README.md b/docs/README.md index 0b9e6b5..d1b0682 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ * [Advanced](/docs/advanced/README.md) * [Invalidation](/docs/advanced/Invalidation.md) * [Views](/docs/advanced/ViewOf.md) + * [Deduplication](/docs/advanced/Deduplication.md) * [Custom ID](/docs/advanced/CustomId.md) * [Plugins](/docs/advanced/Plugins.md) * [Cheat Sheet](/docs/advanced/Plugins-CheatSheet.md) diff --git a/docs/advanced/Deduplication.md b/docs/advanced/Deduplication.md new file mode 100644 index 0000000..987ca11 --- /dev/null +++ b/docs/advanced/Deduplication.md @@ -0,0 +1,30 @@ +# Deduplication + +Ladda tries to optimize "READ" operations by deduplicating identical +simultaneous requests and therefore reduce the load both on server and +client. + +*Identical* means calling the same function with identical arguments. +
+*Simultaneous* means that another call has been made before the first +call has been resolved or rejected. + +Given the following code, where `getUsers` is a "READ" operation: + +```javascript +// Component 1 +api.user.getUsers(); + +// Component 2 +api.user.getUsers(); + +// Component 3 +api.user.getUsers(); +``` + +Ladda will only make a single call to `getUsers` and distribute its +result to all callers. + + +This feature can be disabled on a global, an entity and a function +level. Check the [Configuration Reference](/docs/basics/Configuration.md) for details. diff --git a/docs/basics/Configuration.md b/docs/basics/Configuration.md index 0382434..f147cf9 100644 --- a/docs/basics/Configuration.md +++ b/docs/basics/Configuration.md @@ -14,6 +14,9 @@ There are a handful optional options to configure Ladda. In a minimal configurat * **api** (required): An object of ApiFunctions, functions that communicate with an external service and return a Promise. The function name as key and function as value. +* **noDedup**: `true | false` Disable [deduplication](/docs/advanced/Deduplication.md) + of all "READ" operations for this entity. Defaults to false. + ## Method Configuration * **operation**: `"CREATE" | "READ" | "UPDATE" | "DELETE" | "NO_OPERATION"`. Default is `"NO_OPERATION"`. @@ -29,6 +32,12 @@ There are a handful optional options to configure Ladda. In a minimal configurat call (if any), looking up items from the cache and only calling for items that are not yet present. Defaults to false. +* **noDedup**: `true | false` Disable [deduplication](/docs/advanced/Deduplication.md) + a "READ" operation. Defaults to false. + ## Ladda Configuration * **idField**: Specify the default property that contains the ID. By default this is `"id"`. + +* **noDedup**: `true | false` Disable [deduplication](/docs/advanced/Deduplication.md) + of "READ" operation for all entities. Defaults to false. From e80d05955a6dd199c6fc0451e00375dcf363947c Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 20:31:53 +0200 Subject: [PATCH 134/136] Add noDedup to defaults in builder --- src/builder.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/builder.js b/src/builder.js index c660985..c2cb2d1 100644 --- a/src/builder.js +++ b/src/builder.js @@ -73,6 +73,7 @@ const setEntityConfigDefaults = ec => { ttl: 300, invalidates: [], invalidatesOn: ['CREATE', 'UPDATE', 'DELETE'], + noDedup: false, ...ec }; }; @@ -84,7 +85,8 @@ const setApiConfigDefaults = ec => { invalidates: [], idFrom: 'ENTITY', byId: false, - byIds: false + byIds: false, + noDedup: false }; const writeToObjectIfNotSet = curry((o, [k, v]) => { @@ -115,6 +117,7 @@ export const getEntityConfigs = compose( // exported for testing const getGlobalConfig = (config) => ({ idField: 'id', + noDedup: false, useProductionBuild: process.NODE_ENV === 'production', ...(config.__config || {}) }); From b6dcc6c4b86cbceb5365c23cee52d76aa3735dd0 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 20:40:50 +0200 Subject: [PATCH 135/136] Add noDedup validations --- src/validator.js | 20 ++++++++++++++++++-- src/validator.spec.js | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/validator.js b/src/validator.js index 46b3aa6..3aa977e 100644 --- a/src/validator.js +++ b/src/validator.js @@ -30,7 +30,7 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) compose( // eslint-disable-next-line no-unused-vars map_(([fnName, fn]) => { - const { operation, invalidates, idFrom, byId, byIds } = fn; + const { operation, invalidates, idFrom, byId, byIds, noDedup } = fn; const fullName = `${entityName}.${fnName}`; if (!isOperation(operation)) { warnApi( @@ -51,6 +51,12 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) ); } + if (typeof noDedup !== 'boolean') { + warnApi( + `${fullName}'s noDedup needs to be a boolean, was ${typeof noDedup}'` + ); + } + if (typeof idFrom !== 'function' && !isIdFromString(idFrom)) { warnApi( `${fullName} defines illegal idFrom. Use 'ENTITY', 'ARGS', or a function (Entity => id)` @@ -113,12 +119,22 @@ const checkTTL = (logger, entityConfigs, config, entityName, entity) => { } }; +const checkNoDedup = (logger, entityConfigs, config, entityName, entity) => { + if (typeof entity.noDedup !== 'boolean') { + warn( + logger, + `Entity ${entityName} specified noDedup as ${typeof entity.noDedup}, needs to be a boolean` + ); + } +}; + const checkEntities = (logger, entityConfigs, config) => { const checks = [ checkApiDeclaration, checkViewOf, checkInvalidations, - checkTTL + checkTTL, + checkNoDedup ]; compose( diff --git a/src/validator.spec.js b/src/validator.spec.js index 00bc416..bbfbade 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -146,6 +146,27 @@ describe('validateConfig', () => { expect(logger.error.args[0][0]).to.match(/activity.*ttl.*string.*needs to be a number/); }); + it('checks for wrong noDedup value', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll: () => {} }, + noDedup: false + }, + activity: { + api: { getAll: () => {} }, + noDedup: 'X' + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/activity.*noDedup.*string.*needs to be a boolean/); + }); + + it('checks for wrong api operations', () => { const logger = createLogger(); @@ -202,6 +223,25 @@ describe('validateConfig', () => { expect(logger.error.args[0][0]).to.match(/user.getAll.*byIds.*string/); }); + it('checks for wrong api noDedup definition', () => { + const logger = createLogger(); + + const getAll = () => {}; + getAll.operation = 'READ'; + getAll.noDedup = 'X'; + + const eConfigs = getEntityConfigs({ + user: { + api: { getAll } + } + }); + const config = {}; + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/user.getAll.*noDedup.*string/); + }); + it('checks for wrong api idFrom (illegal type)', () => { const logger = createLogger(); From e9a39e2bd15c8a290f827d2900754b18b7772301 Mon Sep 17 00:00:00 2001 From: LFDM <1986gh@gmail.com> Date: Mon, 24 Apr 2017 20:48:40 +0200 Subject: [PATCH 136/136] ADd global config validation --- src/validator.js | 29 ++++++++++++----- src/validator.spec.js | 75 +++++++++++++++++++++++++++++++++---------- 2 files changed, 79 insertions(+), 25 deletions(-) diff --git a/src/validator.js b/src/validator.js index 3aa977e..0f3958c 100644 --- a/src/validator.js +++ b/src/validator.js @@ -13,7 +13,7 @@ const isValidLogger = (logger) => logger && typeof logger.error === 'function'; const getEntityNames = (entityConfigs) => Object.keys(entityConfigs); -const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) => { +const checkApiDeclaration = (logger, entityConfigs, entityName, entity) => { if (typeof entity.api !== 'object') { warn(logger, `No api definition found for entity ${entityName}`); return; @@ -76,7 +76,7 @@ const checkApiDeclaration = (logger, entityConfigs, config, entityName, entity) )(entity.api); }; -const checkViewOf = (logger, entityConfigs, config, entityName, entity) => { +const checkViewOf = (logger, entityConfigs, entityName, entity) => { const { viewOf } = entity; if (viewOf && !isConfigured(viewOf, entityConfigs)) { warn( @@ -87,7 +87,7 @@ const checkViewOf = (logger, entityConfigs, config, entityName, entity) => { } }; -const checkInvalidations = (logger, entityConfigs, config, entityName, entity) => { +const checkInvalidations = (logger, entityConfigs, entityName, entity) => { const { invalidates, invalidatesOn } = entity; map_((entityToInvalidate) => { if (!isConfigured(entityToInvalidate, entityConfigs)) { @@ -110,7 +110,7 @@ const checkInvalidations = (logger, entityConfigs, config, entityName, entity) = }, invalidatesOn); }; -const checkTTL = (logger, entityConfigs, config, entityName, entity) => { +const checkTTL = (logger, entityConfigs, entityName, entity) => { if (typeof entity.ttl !== 'number') { warn( logger, @@ -119,7 +119,7 @@ const checkTTL = (logger, entityConfigs, config, entityName, entity) => { } }; -const checkNoDedup = (logger, entityConfigs, config, entityName, entity) => { +const checkNoDedup = (logger, entityConfigs, entityName, entity) => { if (typeof entity.noDedup !== 'boolean') { warn( logger, @@ -128,7 +128,7 @@ const checkNoDedup = (logger, entityConfigs, config, entityName, entity) => { } }; -const checkEntities = (logger, entityConfigs, config) => { +const checkEntities = (logger, entityConfigs) => { const checks = [ checkApiDeclaration, checkViewOf, @@ -139,12 +139,23 @@ const checkEntities = (logger, entityConfigs, config) => { compose( map_(([entityName, entity]) => { - map_((check) => check(logger, entityConfigs, config, entityName, entity), checks); + map_((check) => check(logger, entityConfigs, entityName, entity), checks); }), toPairs )(entityConfigs); }; +const checkGlobalConfig = (logger, config) => { + const { noDedup, idField } = config; + if (typeof noDedup !== 'boolean') { + warn(logger, 'noDedup needs to be a boolean, was string'); + } + + if (typeof idField !== 'string') { + warn(logger, 'idField needs to be a string, was boolean'); + } +}; + export const validateConfig = (logger, entityConfigs, config) => { // do not remove the process.NODE_ENV check here - allows uglifiers @@ -153,5 +164,7 @@ export const validateConfig = (logger, entityConfigs, config) => { return; } - checkEntities(logger, entityConfigs, config); + + checkGlobalConfig(logger, config); + checkEntities(logger, entityConfigs); }; diff --git a/src/validator.spec.js b/src/validator.spec.js index bbfbade..997f127 100644 --- a/src/validator.spec.js +++ b/src/validator.spec.js @@ -7,13 +7,20 @@ const createLogger = () => ({ error: sinon.spy() }); +const createGlobalConfig = (conf) => ({ + idField: 'id', + noDedup: false, + useProductionBuild: false, + ...conf +}); + describe('validateConfig', () => { it('does not do anything when using production build', () => { const logger = createLogger(); const eConfigs = getEntityConfigs({ user: {} }); - const config = { useProductionBuild: true }; + const config = createGlobalConfig({ useProductionBuild: true }); validateConfig(logger, eConfigs, config); expect(logger.error).not.to.have.been.called; @@ -25,12 +32,46 @@ describe('validateConfig', () => { const eConfigs = getEntityConfigs({ user: {} }); - const config = { useProductionBuild: true }; + const config = createGlobalConfig({ useProductionBuild: true }); validateConfig(invalidLogger, eConfigs, config); expect(invalidLogger.x).not.to.have.been.called; }); + it('checks the global config object - idField', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { + getAll: () => {} + } + } + }); + const config = createGlobalConfig({ idField: true }); + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/idField.*string.*was.*boolean/); + }); + + it('checks the global config object - noDedup', () => { + const logger = createLogger(); + + const eConfigs = getEntityConfigs({ + user: { + api: { + getAll: () => {} + } + } + }); + const config = createGlobalConfig({ noDedup: 'X' }); + + validateConfig(logger, eConfigs, config); + expect(logger.error).to.have.been.called; + expect(logger.error.args[0][0]).to.match(/noDedup.*boolean.*was.*string/); + }); + it('checks for missing api declarations', () => { const logger = createLogger(); @@ -42,7 +83,7 @@ describe('validateConfig', () => { }, activity: {} }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -71,7 +112,7 @@ describe('validateConfig', () => { viewOf: 'mdiumUser' // typo! } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -95,7 +136,7 @@ describe('validateConfig', () => { invalidates: ['activity'] } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -119,7 +160,7 @@ describe('validateConfig', () => { invalidatesOn: ['X'] } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -139,7 +180,7 @@ describe('validateConfig', () => { ttl: 'xxx' } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -159,7 +200,7 @@ describe('validateConfig', () => { noDedup: 'X' } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -178,7 +219,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -197,7 +238,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -216,7 +257,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -235,7 +276,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -254,7 +295,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -273,7 +314,7 @@ describe('validateConfig', () => { api: { getAll } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -298,7 +339,7 @@ describe('validateConfig', () => { api: { getAll, getSome, getOne } } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.called; @@ -318,7 +359,7 @@ describe('validateConfig', () => { invalidates: ['X'] } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).to.have.been.calledTwice; @@ -341,7 +382,7 @@ describe('validateConfig', () => { ttl: 400 } }); - const config = {}; + const config = createGlobalConfig({}); validateConfig(logger, eConfigs, config); expect(logger.error).not.to.have.been.called;