Skip to content

Instantly share code, notes, and snippets.

@justinobney
Last active November 7, 2016 18:38
Show Gist options
  • Save justinobney/61efda3b69eaa5abaff0cd609ca1360c to your computer and use it in GitHub Desktop.
Save justinobney/61efda3b69eaa5abaff0cd609ca1360c to your computer and use it in GitHub Desktop.

dataloader clone

  • exercise in cloning facebook/dataloader without reading source

Ex

// fake api call accepting [...keys]
function getData(keys) {
  return Promise.resolve(
    keys.map(x => ({ id:x, prop: `foo prop ${x}`}))
  );
}

// creation
const loader = new Loader(keys => getData(keys), opts?);

// use
loader.load(1)

// later in code...
loader.load(2)

// calls within ~5ms get batched
// resulting in getData([1,2])

// ex: React rendering a list of containers passing in only id's.
// containers then call load in cwm
// resulting in batched/cached api calls

each call to loader.load(key) returns a promise that will resolve with the single object loading.

creation options

  • wait: the number ms to collect for batchning (default: 5)
  • mapKeyFn: fn to get key for caching (default: x=>x.id)
  • limit: max queue size before flushing batch (default: infinite)
import { Promise } from 'es6-promise';
const DEFAULT_MAP_ID = x => x.id;
export default function Loader(
batchCall,
{wait = 5, mapKeyFn = DEFAULT_MAP_ID, limit = -1} = {}
) {
let cache = {};
const queuedKeys = [];
const processBatch = () => {
const args = [...queuedKeys];
queuedKeys.length = 0;
return batchCall(args).then(
resp => resp.forEach(x =>
cache[mapKeyFn(x)].resolve(x)
),
error => args.forEach(x =>
cache[x].reject(new Error('batch call failed'))
)
);
};
const debouncedProcessBatch = debounce(processBatch, wait);
return {
load(key) {
if (!cache[key]) {
cache[key] = getResolvablePromise();
queuedKeys.push(key);
if(limit && queuedKeys.length == limit) {
debouncedProcessBatch.cancel();
processBatch();
} else {
debouncedProcessBatch();
}
}
return cache[key];
},
clear(key) {
if (key) {
delete cache[key];
} else {
cache = {};
}
},
flush() {
debouncedProcessBatch.cancel();
processBatch();
}
}
}
function getResolvablePromise() {
let resolve;
let reject;
const p = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
p.resolve = (val) => resolve(val);
p.reject = (val) => reject(val);
return p;
}
function debounce(func, wait, immediate) {
let timeout;
fn.cancel = () => clearTimeout(timeout);
return fn;
function fn() {
let context = this, args = arguments;
let callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
func.apply(context, args);
}
function later() {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
}
};
};
import Loader from '../src/loader.js';
import { Promise } from 'es6-promise';
describe('Loader', () => {
jasmine.clock().install();
afterEach(() => {
jasmine.clock().tick(5);
})
describe('batch success', () => {
beforeEach(function () {
spyOn(obj, 'getData').and.callThrough();
});
it('debounces the call', () => {
const loader = new Loader(keys => obj.getData(keys));
loader.load(1);
expect(obj.getData).not.toHaveBeenCalledWith([1]);
jasmine.clock().tick(5);
expect(obj.getData).toHaveBeenCalledWith([1]);
});
it('collects the arguments', () => {
const loader = new Loader(keys => obj.getData(keys));
loader.load(1);
loader.load(2);
expect(obj.getData).not.toHaveBeenCalledWith([1,2]);
jasmine.clock().tick(5);
expect(obj.getData).toHaveBeenCalledWith([1,2]);
expect(obj.getData.calls.count()).toEqual(1);
});
it('can set max batch limit', () => {
const loader = new Loader(
keys => obj.getData(keys),
{limit:2}
);
loader.load(1);
loader.load(2);
loader.load(3);
expect(obj.getData).toHaveBeenCalledWith([1,2]);
expect(obj.getData).not.toHaveBeenCalledWith([3]);
expect(obj.getData.calls.count()).toEqual(1);
jasmine.clock().tick(5);
expect(obj.getData).toHaveBeenCalledWith([3]);
expect(obj.getData.calls.count()).toEqual(2);
});
it('can force execution', () => {
const loader = new Loader(
keys => obj.getData(keys));
loader.load(1);
loader.load(2);
loader.flush();
expect(obj.getData).toHaveBeenCalledWith([1,2]);
expect(obj.getData.calls.count()).toEqual(1);
});
it('can clear cache', () => {
const loader = new Loader(
keys => obj.getData(keys)
);
loader.load(1);
jasmine.clock().tick(5);
expect(obj.getData.calls.count()).toEqual(1);
loader.clear(1);
loader.load(1);
jasmine.clock().tick(5);
expect(obj.getData.calls.count()).toEqual(2);
});
it('can clear all', () => {
const loader = new Loader(
keys => obj.getData(keys)
);
loader.load(1);
jasmine.clock().tick(5);
expect(obj.getData.calls.count()).toEqual(1);
loader.load(2);
jasmine.clock().tick(5);
expect(obj.getData.calls.count()).toEqual(2);
loader.clear();
loader.load(1);
loader.load(2);
jasmine.clock().tick(5);
expect(obj.getData.calls.count()).toEqual(3);
expect(obj.getData).toHaveBeenCalledWith([1,2]);
});
it('does not duplicate keys', () => {
const loader = new Loader(keys => obj.getData(keys));
loader.load(1);
loader.load(1);
jasmine.clock().tick(5);
expect(obj.getData).toHaveBeenCalledWith([1]);
});
it('returns a promise', () => {
const loader = new Loader(keys => obj.getData(keys));
const promise = loader.load(1);
expect(promise.then).toBeDefined();
});
it('maps keys to promises', () => {
const loader = new Loader(keys => obj.getData(keys));
const promiseA = loader.load(1);
const promiseB = loader.load(1);
expect(promiseA).toEqual(promiseB);
});
it('returned promises should resolve with individual items', (done) => {
const loader = new Loader(keys => obj.getData(keys));
const promiseA = loader.load(2).then(resp => {
expect(resp.id).toEqual(2);
});
const promiseB = loader.load(1).then(resp => {
expect(resp.id).toEqual(1);
});
jasmine.clock().tick(5);
expect(obj.getData).toHaveBeenCalledWith([2,1]);
expect(obj.getData.calls.count()).toBe(1);
Promise.all([promiseA, promiseB]).then(() => {
done();
});
});
})
describe('batch fails', () => {
beforeEach(function () {
spyOn(obj, 'getData').and.returnValue(Promise.reject());
});
it('batch fails should reject promises', (done) => {
const loader = new Loader(keys => obj.getData(keys));
const rejectionA = jasmine.createSpy(`'load 1 failed'`);
const rejectionB = jasmine.createSpy(`'load 2 failed'`);
const promiseA = loader.load(1).then(undefined, rejectionA);
const promiseB = loader.load(2).then(undefined, rejectionB);
jasmine.clock().tick(5);
Promise.all([promiseA, promiseB]).then(() => {
expect(obj.getData.calls.count()).toBe(1);
expect(rejectionA).toHaveBeenCalled();
expect(rejectionB).toHaveBeenCalled();
done();
});
});
})
});
const obj = {
getData(keys) {
return Promise.resolve(
keys.map(
x => ({
id:x,
prop: `foo prop ${x}`
})
)
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment