Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
lib: add cache utility
  • Loading branch information
joyeecheung committed Jun 1, 2018
commit 3e614effe5e8ace69debcee9c926a4fe317efeaf
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ tmp
coverage.lcov
tmp-*
.eslintcache
.ncu
103 changes: 103 additions & 0 deletions lib/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict';

const path = require('path');
const fs = require('fs');
const { writeJson, readJson, writeFile, readFile } = require('./file');

function isAsync(fn) {
return fn[Symbol.toStringTag] === 'AsyncFunction';
}

class Cache {
constructor(dir) {
this.dir = dir || path.join(__dirname, '..', '.ncu', 'cache');
this.originals = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd would have prefer this to be a Map instead of plain object, for readability.

this.disabled = true;
}

disable() {
this.disabled = true;
}

enable() {
this.disabled = false;
}

getFilename(key, ext) {
return path.join(this.dir, key) + ext;
}

has(key, ext) {
if (this.disabled) {
return false;
}

return fs.existsSync(this.getFilename(key, ext));
}

get(key, ext) {
if (!this.has(key, ext)) {
return undefined;
}
if (ext === '.json') {
return readJson(this.getFilename(key, ext));
} else {
return readFile(this.getFilename(key, ext));
}
}

write(key, ext, content) {
if (this.disabled) {
return;
}
const filename = this.getFilename(key, ext);
if (ext === '.json') {
return writeJson(filename, content);
} else {
return writeFile(filename, content);
}
}

wrapAsync(original, identity) {
const cache = this;
return async function(...args) {
const { key, ext } = identity.call(this, ...args);
const cached = cache.get(key, ext);
if (cached) {
return cached;
}
const result = await original.call(this, ...args);
cache.write(key, ext, result);
return result;
};
}

wrapNormal(original, identity) {
const cache = this;
return function(...args) {
const { key, ext } = identity.call(this, ...args);
const cached = cache.get(key, ext);
if (cached) {
return cached;
}
const result = original.call(this, ...args);
cache.write(key, ext, result);
return result;
};
}

wrap(Class, identities) {
for (let method of Object.keys(identities)) {
const original = Class.prototype[method];
const identity = identities[method];
this.originals[method] = original;
if (isAsync(original)) {
Class.prototype[method] = this.wrapAsync(original, identity);
} else {
Class.prototype[method] = this.wrapNormal(original, identity);
}
}
}
}

module.exports = Cache;
24 changes: 24 additions & 0 deletions test/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict';

const path = require('path');
const rimraf = require('rimraf');
const mkdirp = require('mkdirp');
const fs = require('fs');

exports.tmpdir = {
get path() {
return path.join(__dirname, 'tmp');
},
refresh() {
rimraf.sync(this.path);
mkdirp.sync(this.path);
}
};

exports.copyShallow = function(src, dest) {
mkdirp.sync(dest);
const list = fs.readdirSync(src);
for (const file of list) {
fs.copyFileSync(path.join(src, file), path.join(dest, file));
}
};
116 changes: 116 additions & 0 deletions test/unit/cache.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use strict';

const Cache = require('../../lib/cache');
const { tmpdir } = require('../common');
const path = require('path');
const fs = require('fs');
const assert = require('assert');

describe('Cache', () => {
const syncResult = 'content in sync';
const asyncResult = {
results: 'content in async.json'
};

class CachedClass {
constructor(foo) {
this.foo = foo;
this.sync = 0;
this.async = 0;
}

cachedSyncMethod(...args) {
this.sync++;
return syncResult;
}

async cachedAsyncMethod(...args) {
this.async++;
const p = Promise.resolve(asyncResult);
const result = await p; // make sure it's async
return result;
}

getCacheKey(prefix, ...args) {
return `${prefix}-${args.join('-')}-${this.foo}`;
}
}

tmpdir.refresh();
const cache = new Cache(tmpdir.path);
cache.wrap(CachedClass, {
cachedSyncMethod(...args) {
return { key: this.getCacheKey('sync', ...args), ext: '.txt' };
},
cachedAsyncMethod(...args) {
return { key: this.getCacheKey('async', ...args), ext: '.json' };
}
});

it('should cache sync results', () => {
tmpdir.refresh();
cache.enable();
const expected = syncResult;
const instance = new CachedClass('foo');
let actual = instance.cachedSyncMethod('test');
assert.strictEqual(instance.sync, 1);
assert.strictEqual(actual, expected);

let syncCache = path.join(tmpdir.path, 'sync-test-foo.txt');
let cached = fs.readFileSync(syncCache, 'utf8');
assert.strictEqual(cached, expected);

// Call it again
actual = instance.cachedSyncMethod('test');
assert.strictEqual(instance.sync, 1);
assert.strictEqual(actual, expected);

syncCache = path.join(tmpdir.path, 'sync-test-foo.txt');
cached = fs.readFileSync(syncCache, 'utf8');
assert.strictEqual(cached, expected);
});

it('should cache async results', async() => {
tmpdir.refresh();
cache.enable();
const expected = Object.assign({}, asyncResult);
const instance = new CachedClass('foo');
let actual = await instance.cachedAsyncMethod('test');
assert.strictEqual(instance.async, 1);
assert.deepStrictEqual(actual, expected);

let asyncCache = path.join(tmpdir.path, 'async-test-foo.json');
let cached = JSON.parse(fs.readFileSync(asyncCache, 'utf8'));
assert.deepStrictEqual(cached, expected);

// Call it again
actual = await instance.cachedAsyncMethod('test');
assert.strictEqual(instance.async, 1);
assert.deepStrictEqual(actual, expected);

asyncCache = path.join(tmpdir.path, 'async-test-foo.json');
cached = JSON.parse(fs.readFileSync(asyncCache, 'utf8'));
assert.deepStrictEqual(cached, expected);
});

it('should not cache if disabled', async() => {
tmpdir.refresh();
cache.disable();
const expected = Object.assign({}, asyncResult);
const instance = new CachedClass('foo');
let actual = await instance.cachedAsyncMethod('test');
assert.strictEqual(instance.async, 1);
assert.deepStrictEqual(actual, expected);

let list = fs.readdirSync(tmpdir.path);
assert.deepStrictEqual(list, []);

// Call it again
actual = await instance.cachedAsyncMethod('test');
assert.strictEqual(instance.async, 2);
assert.deepStrictEqual(actual, expected);

list = fs.readdirSync(tmpdir.path);
assert.deepStrictEqual(list, []);
});
});