Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More dedup and a more expressive ChangeObject #26

Merged
merged 22 commits into from
May 1, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions docs/advanced/Plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,20 @@ following shape:

```javascript
{
type: 'UPDATE' | 'REMOVE',
operation: 'CREATE' | 'READ' | 'UPDATE' | 'DELETE' | 'NO_OPERATION',
entity: EntityName,
entities: EntityValue[]
apiFn: ApiFunctionName,
args: Any[] | null
values: 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.
It provides all information about which call triggered a change,
including the arguments array.

The `values` field is guaranteed to be a list of EntityValues, even if
a change only affects a single entity. The only expection are
`NO_OPERATION` operations, which will always return `null` here.

`addChangeListener` returns a deregistration function. Call it to stop
listening for changes.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ladda-cache",
"version": "0.2.1",
"version": "0.2.3",
"description": "Data fetching layer with support for caching",
"main": "dist/bundle.js",
"dependencies": {
Expand Down
8 changes: 7 additions & 1 deletion src/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ const applyPlugin = curry((addChangeListener, config, entityConfigs, plugin) =>
return mapApiFunctions(pluginDecorator, entityConfigs);
});

const createPluginList = (core, plugins) => {
return plugins.length ?
[core, dedupPlugin, ...plugins, dedupPlugin] :
[core, dedupPlugin];
};

// Config -> Api
export const build = (c, ps = []) => {
const config = getGlobalConfig(c);
Expand All @@ -136,5 +142,5 @@ export const build = (c, ps = []) => {
const applyPlugin_ = applyPlugin(listenerStore.addChangeListener, config);
const applyPlugins = reduce(applyPlugin_, entityConfigs);
const createApi = compose(toApi, applyPlugins);
return createApi([cachePlugin(listenerStore.onChange), ...ps, dedupPlugin]);
return createApi(createPluginList(cachePlugin(listenerStore.onChange), ps));
};
96 changes: 88 additions & 8 deletions src/builder.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ getUsers.operation = 'READ';
const deleteUser = () => Promise.resolve();
deleteUser.operation = 'DELETE';

const noopUser = () => Promise.resolve(['a', 'b']);
noopUser.operation = 'NO_OPERATION';

const config = () => ({
user: {
ttl: 300,
api: {
getUsers,
deleteUser
deleteUser,
noopUser
},
invalidates: ['alles']
},
Expand Down Expand Up @@ -152,6 +156,23 @@ describe('builder', () => {
.then(() => done());
});

it('applies dedup before and after the plugins, if there are any', () => {
const getAll = sinon.stub().returns(Promise.resolve([]));
getAll.operation = 'READ';
const conf = { test: { api: { getAll } } };
const plugin = () => ({ fn }) => () => {
fn();
fn();
return fn();
};
const api = build(conf, [plugin]);
api.test.getAll();
api.test.getAll();
return api.test.getAll().then(() => {
expect(getAll).to.have.been.calledOnce;
});
});

describe('change listener', () => {
it('exposes Ladda\'s listener/onChange interface to plugins', () => {
const plugin = ({ addChangeListener }) => {
Expand All @@ -162,22 +183,81 @@ describe('builder', () => {
build(config(), [plugin]);
});

it('allows plugins to add a listener, which gets notified on all cache changes', () => {
it('returns a deregistration fn', () => {
const spy = sinon.spy();

const plugin = ({ addChangeListener }) => {
addChangeListener(spy);
const deregister = addChangeListener(spy);
deregister();
return ({ fn }) => fn;
};

const api = build(config(), [plugin]);

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);
expect(spy).not.to.have.been.called;
});
});

it('can call deregistration fn several times without harm', () => {
const spy = sinon.spy();

const plugin = ({ addChangeListener }) => {
const deregister = addChangeListener(spy);
deregister();
deregister();
deregister();
return ({ fn }) => fn;
};

const api = build(config(), [plugin]);

return api.user.getUsers().then(() => {
expect(spy).not.to.have.been.called;
});
});

describe('allows plugins to add a listener, which gets notified on all cache changes', () => {
it('on READ operations', () => {
const spy = sinon.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;
const changeObject = spy.args[0][0];
expect(changeObject.entity).to.equal('user');
expect(changeObject.apiFn).to.equal('getUsers');
expect(changeObject.operation).to.equal('READ');
expect(changeObject.values).to.deep.equal(users);
expect(changeObject.args).to.deep.equal([]);
});
});

it('on NO_OPERATION operations', () => {
const spy = sinon.spy();

const plugin = ({ addChangeListener }) => {
addChangeListener(spy);
return ({ fn }) => fn;
};

const api = build(config(), [plugin]);

return api.user.noopUser('x').then(() => {
expect(spy).to.have.been.calledOnce;
const changeObject = spy.args[0][0];
expect(changeObject.entity).to.equal('user');
expect(changeObject.apiFn).to.equal('noopUser');
expect(changeObject.operation).to.equal('NO_OPERATION');
expect(changeObject.values).to.deep.equal(null);
expect(changeObject.args).to.deep.equal(['x']);
});
});
});

Expand Down
20 changes: 5 additions & 15 deletions src/plugins/cache/entity-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* 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 {curry, reduce, map_, clone} from 'ladda-fp';
import {merge} from './merger';
import {removeId} from './id-helper';

Expand Down Expand Up @@ -50,16 +50,6 @@ 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: e.name, // real name, not getEntityType, which takes views into account!
entities: removeId(xs)
}));

// Function -> Function -> EntityStore -> Entity -> Value -> a
const handle = curry((viewHandler, entityHandler, s, e, v) => {
if (isView(e)) {
Expand Down Expand Up @@ -102,7 +92,6 @@ const setViewValue = (s, e, v) => {
// EntityStore -> Entity -> [Value] -> ()
export const mPut = curry((es, e, xs) => {
map_(handle(setViewValue, setEntityValue)(es, e))(xs);
triggerHook(es, e, 'UPDATE', xs);
});

// EntityStore -> Entity -> Value -> ()
Expand All @@ -129,14 +118,15 @@ const getViewValue = (s, e, id) => {
// EntityStore -> Entity -> String -> ()
export const get = handle(getViewValue, getEntityValue);

// EntityStore -> Entity -> String -> ()
// EntityStore -> Entity -> String -> Value
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, e, 'DELETE', [x.value]);
return removeId(x.value);
}
return undefined;
};

// EntityStore -> Entity -> String -> Bool
Expand All @@ -163,4 +153,4 @@ const registerEntity = ([eMap, ...other], e) => {
const updateIndex = (m, e) => { return isView(e) ? registerView(m, e) : registerEntity(m, e); };

// [Entity] -> EntityStore
export const createEntityStore = (c, hook = noop) => reduce(updateIndex, [{}, {}, hook], c);
export const createEntityStore = (c) => reduce(updateIndex, [{}, {}], c);
56 changes: 0 additions & 56 deletions src/plugins/cache/entity-store.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
/* eslint-disable no-unused-expressions */

import sinon from 'sinon';
import {createEntityStore, put, mPut, get, contains, remove} from './entity-store';
import {addId} from './id-helper';

Expand Down Expand Up @@ -232,59 +231,4 @@ 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',
entity: 'user',
entities: [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]);
});
});
});
});
25 changes: 22 additions & 3 deletions src/plugins/cache/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {values} from 'ladda-fp';
import {curry, values} from 'ladda-fp';
import {createCache} from './cache';
import {decorateCreate} from './operations/create';
import {decorateRead} from './operations/read';
Expand All @@ -14,10 +14,29 @@ const HANDLERS = {
NO_OPERATION: decorateNoOperation
};

const normalizeFnName = (fnName) => fnName.replace(/^bound /, '');
const normalizePayload = payload => {
if (payload === null) {
return payload;
}
return Array.isArray(payload) ? payload : [payload];
};

const notify = curry((onChange, entity, fn, args, payload) => {
onChange({
operation: fn.operation,
entity: entity.name,
apiFn: normalizeFnName(fn.name),
values: normalizePayload(payload),
args
});
});

export const cachePlugin = (onChange) => ({ config, entityConfigs }) => {
const cache = createCache(values(entityConfigs), onChange);
const cache = createCache(values(entityConfigs));
return ({ entity, fn }) => {
const handler = HANDLERS[fn.operation];
return handler(config, cache, entity, fn);
const notify_ = notify(onChange, entity, fn);
return handler(config, cache, notify_, entity, fn);
};
};
5 changes: 3 additions & 2 deletions src/plugins/cache/operations/create.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import {passThrough, compose} from 'ladda-fp';
import {storeEntity, invalidateQuery} from '../cache';
import {addId} from '../id-helper';

export function decorateCreate(c, cache, e, aFn) {
export function decorateCreate(c, cache, notify, e, aFn) {
return (...args) => {
return aFn(...args)
.then(passThrough(() => invalidateQuery(cache, e, aFn)))
.then(passThrough(compose(storeEntity(cache, e), addId(c, aFn, args))));
.then(passThrough(compose(storeEntity(cache, e), addId(c, aFn, args))))
.then(passThrough(notify(args)));
};
}
Loading