Skip to content

Commit

Permalink
feat: LRUCache/LRUMap family -- inspect and toString improvements
Browse files Browse the repository at this point in the history
* LRUCache and family .inspect limits its output -- showing the youngest items, and ellipsis, and the oldest item. Options allow dumping the raw object or controlling the size of output (and the number of items a console.log will mindlessly iterate over).
* LRUCache and family all have inspect wired up to the magic 'nodejs.util.inspect.custom' symbol property that drives console.log output
* LRUCache and family all have a summaryString method returning eg 'LRUCache[8/200]' for a cache with size 8 and capacity 200, wired to the magic Symbol.toStringTag property that drives string interpolation (partially addresses Yomguithereal#129).
  • Loading branch information
mrflip committed Aug 7, 2022
1 parent 44b77af commit d858639
Show file tree
Hide file tree
Showing 6 changed files with 223 additions and 19 deletions.
14 changes: 13 additions & 1 deletion lru-cache-with-delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,20 @@ function LRUCacheWithDelete(Keys, Values, capacity) {

for (var k in LRUCache.prototype)
LRUCacheWithDelete.prototype[k] = LRUCache.prototype[k];
if (typeof Symbol !== 'undefined')

/**
* If possible, attaching the
* * #.entries method to Symbol.iterator (allowing `for (const foo of cache) { ... }`)
* * the summaryString method to Symbol.toStringTag (allowing `\`${cache}\`` to work)
* * the inspect method to Symbol.for('nodejs.util.inspect.custom') (making `console.log(cache)` liveable)
*/
if (typeof Symbol !== 'undefined') {
LRUCacheWithDelete.prototype[Symbol.iterator] = LRUCache.prototype[Symbol.iterator];
Object.defineProperty(LRUCacheWithDelete.prototype, Symbol.toStringTag, {
get: function () { return `${this.constructor.name}:${this.size}/${this.capacity}`; },
});
LRUCacheWithDelete.prototype[Symbol.for('nodejs.util.inspect.custom')] = LRUCache.prototype.inspect;
}

/**
* Method used to clear the structure.
Expand Down
51 changes: 41 additions & 10 deletions lru-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
var Iterator = require('obliterator/iterator'),
forEach = require('obliterator/foreach'),
typed = require('./utils/typed-arrays.js'),
iterables = require('./utils/iterables.js');
iterables = require('./utils/iterables.js'),
{snipToLast} = require('./utils/snip.js');

/**
* LRUCache.
Expand Down Expand Up @@ -368,29 +369,59 @@ LRUCache.prototype.entries = function() {
});
};

/**
* Return a short string for interpolation: `LRUCache:size/capacity`
*/
LRUCache.prototype.summaryString = function summaryString() {
return `${this.constructor.name}:${this.size}/${this.capacity}`;
};

/**
* Attaching the #.entries method to Symbol.iterator if possible.
*/
if (typeof Symbol !== 'undefined')
if (typeof Symbol !== 'undefined') {
LRUCache.prototype[Symbol.iterator] = LRUCache.prototype.entries;
Object.defineProperty(LRUCache.prototype, Symbol.toStringTag, {
get: function () { return this.summaryString(); },
});
}

LRUCache.defaultMaxToDump = 20;

/**
* Convenience known methods.
* Provide a reasonably-sized view of the object.
*
* @param {number} [depth] - When < 0, only the toString() summary is returned
* @param {object} [options = {}] - settings for output
* @param {boolean} [options.all = false] - When true, returns the object with all properties, ignoring limits
* @param {number} [options.maxToDump = 20] - When size > maxToDump, lists only the
* youngest `maxToDump - 2`, a placeholder with the number
* omitted, and the single oldest item. The secret variable
* LRUCache.defaultMaxToDump determines the default limit.
* @return {Map}
*
*/
LRUCache.prototype.inspect = function() {
LRUCache.prototype.inspect = function(depth, options = {}) {
if (arguments.length <= 1) { options = depth || {}; depth = 2; }
if (options.all) { var ret = {}; Object.assign(ret, this); return ret; }
if (depth < 0) { return this.toString(); }
var maxToDump = options.maxToDump || LRUCache.defaultMaxToDump;
var proxy = new Map();

var iterator = this.entries(),
step;

while ((step = iterator.next(), !step.done))
proxy.set(step.value[0], step.value[1]);
var last = [this.K[this.tail], this.V[this.tail]];
snipToLast(this.entries(), proxy, {maxToDump, size: this.size, last});

// Trick so that node displays the name of the constructor
// Trick so that node displays the name of the constructor (does not work in modern node)
Object.defineProperty(proxy, 'constructor', {
value: LRUCache,
enumerable: false
});
if (typeof Symbol !== 'undefined') {
var self = this;
Object.defineProperty(proxy, Symbol.toStringTag, {
get: function () { return `${self.constructor.name}:${self.size}/${self.capacity}`; },
});
}

return proxy;
};
Expand Down
14 changes: 13 additions & 1 deletion lru-map-with-delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,20 @@ function LRUMapWithDelete(Keys, Values, capacity) {

for (var k in LRUMap.prototype)
LRUMapWithDelete.prototype[k] = LRUMap.prototype[k];
if (typeof Symbol !== 'undefined')

/**
* If possible, attaching the
* * #.entries method to Symbol.iterator (allowing `for (const foo of cache) { ... }`)
* * the summaryString method to Symbol.toStringTag (allowing `\`${cache}\`` to work)
* * the inspect method to Symbol.for('nodejs.util.inspect.custom') (making `console.log(cache)` liveable)
*/
if (typeof Symbol !== 'undefined') {
LRUMapWithDelete.prototype[Symbol.iterator] = LRUMap.prototype[Symbol.iterator];
Object.defineProperty(LRUMapWithDelete.prototype, Symbol.toStringTag, {
get: function () { return `${this.constructor.name}:${this.size}/${this.capacity}`; },
});
LRUMapWithDelete.prototype[Symbol.for('nodejs.util.inspect.custom')] = LRUMap.prototype.inspect;
}

/**
* Method used to clear the structure.
Expand Down
19 changes: 14 additions & 5 deletions lru-map.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,17 +211,26 @@ LRUMap.prototype.forEach = LRUCache.prototype.forEach;
LRUMap.prototype.keys = LRUCache.prototype.keys;
LRUMap.prototype.values = LRUCache.prototype.values;
LRUMap.prototype.entries = LRUCache.prototype.entries;
LRUMap.prototype.summaryString = LRUCache.prototype.summaryString;

/**
* Attaching the #.entries method to Symbol.iterator if possible.
* Inherit methods
*/
if (typeof Symbol !== 'undefined')
LRUMap.prototype[Symbol.iterator] = LRUMap.prototype.entries;
LRUMap.prototype.inspect = LRUCache.prototype.inspect;

/**
* Convenience known methods.
* If possible, attaching the
* * #.entries method to Symbol.iterator (allowing `for (const foo of cache) { ... }`)
* * the summaryString method to Symbol.toStringTag (allowing `\`${cache}\`` to work)
* * the inspect method to Symbol.for('nodejs.util.inspect.custom') (making `console.log(cache)` liveable)
*/
LRUMap.prototype.inspect = LRUCache.prototype.inspect;
if (typeof Symbol !== 'undefined') {
LRUMap.prototype[Symbol.iterator] = LRUMap.prototype.entries;
Object.defineProperty(LRUMap.prototype, Symbol.toStringTag, {
get: function () { return `${this.constructor.name}:${this.size}/${this.capacity}`; },
});
LRUMap.prototype[Symbol.for('nodejs.util.inspect.custom')] = LRUCache.prototype.inspect;
}

/**
* Static @.from function taking an arbitrary iterable & converting it into
Expand Down
120 changes: 118 additions & 2 deletions test/lru-cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ var assert = require('assert'),
LRUMap = require('../lru-map.js'),
LRUCacheWithDelete = require('../lru-cache-with-delete.js'),
LRUMapWithDelete = require('../lru-map-with-delete.js');
var NodeUtil = require('node:util');

function makeTests(Cache, name) {
describe(name, function() {
Expand Down Expand Up @@ -62,7 +63,7 @@ function makeTests(Cache, name) {
assert.strictEqual(cache.peek('two'), 5);
assert.deepStrictEqual(Array.from(cache.entries()), [['three', 3], ['four', 4], ['two', 5]]);

if (name === 'LRUCache' || name === 'LRUCacheWithDelete')
if (/LRUCache/.test(name))
assert.strictEqual(Object.keys(cache.items).length, 3);
else
assert.strictEqual(cache.items.size, 3);
Expand Down Expand Up @@ -222,7 +223,7 @@ function makeTests(Cache, name) {
assert.deepStrictEqual(entries, Array.from(cache.entries()));
});

if ((name === 'LRUCacheWithDelete') || (name === 'LRUMapWithDelete')) {
if (/With/.test(name)) {

it('should be possible to delete keys from a LRU cache.', function() {
var cache = new Cache(3);
Expand Down Expand Up @@ -302,6 +303,7 @@ function makeTests(Cache, name) {
assert.equal(dead, missingMarker);

cache.set('one', 'uno');

cache.set('two', 'dos');
cache.set('three', 'tres');

Expand Down Expand Up @@ -488,6 +490,120 @@ function makeTests(Cache, name) {
});

}

describe('inspection', function() {
function makeExercisedCache(capacity) {
var cache = new Cache(capacity), ii;
cache.set(1, 'a'); cache.set(2, 'b'); cache.set('too old', 'c'); cache.set('oldest', 'd');
for (ii = 0; ii < capacity - 3; ii++) { cache.set(ii * 2, ii * 2); }
cache.set(4, 'D'); cache.set(2, 'B'); cache.get(1);
cache.set(5, 'e'); cache.set(6, 'f');
return cache;
}

it('toString() states the name size and capacity', function () {
var cache = new Cache(null, null, 200, {ttl: 200});
cache.set(0, 'cero'); cache.set(1, 'uno');
assert.deepStrictEqual(cache.toString(), `[object ${name}:2/200]`);
if (typeof Symbol !== 'undefined') {
assert.deepStrictEqual(cache[Symbol.toStringTag], `${name}:2/200`);
}
assert.deepStrictEqual(cache.summaryString(), `${name}:2/200`);
cache.set(2, 'dos'); cache.set(3, 'tres');
assert.deepStrictEqual(cache.toString(), `[object ${name}:4/200]`);
cache = makeExercisedCache(200);
assert.deepStrictEqual(cache.toString(), `[object ${name}:200/200]`);
});

if (typeof Symbol !== 'undefined') {
it('registers its inspect method for the console.log and friends to use', function () {
var cache = makeExercisedCache(7);
assert.deepStrictEqual(cache[Symbol.for('nodejs.util.inspect.custom')], cache.inspect);
});

it('attaches the summaryString method to the magic [Symbol.toStringTag] property', function () {
var cache = makeExercisedCache(7);
assert.deepStrictEqual(cache[Symbol.toStringTag], cache.summaryString());
});
}

it('accepts limits on what inspect returns', function () {
var cache = new Cache(15), inspectedItems;
// empty
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, []);
//
cache.set(1, 'a');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[1, 'a']]);
//
cache.set(2, 'b');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[2, 'b'], [1, 'a']]);
//
cache.set(3, 'c');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[3, 'c'], [2, 'b'], [1, 'a']]);
//
cache.set(4, 'd');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[4, 'd'], [3, 'c'], [2, 'b'], [1, 'a']]);
//
cache.set(5, 'e');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[5, 'e'], [4, 'd'], [3, 'c'], [2, 'b'], [1, 'a']]);
//
cache.set(6, 'f');
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[6, 'f'], [5, 'e'], [4, 'd'], ['_...', 2], [1, 'a']]);
//
var ii;
for (ii = 0; ii < 20; ii++) { cache.set(ii * 2, ii * 2); }
inspectedItems = Array.from(cache.inspect({maxToDump: 5}).entries());
assert.deepStrictEqual(inspectedItems, [[38, 38], [36, 36], [34, 34], ['_...', 11], [10, 10]]);
});

it('puts a reasonable limit on what the console will show (large)', function () {
var cache = makeExercisedCache(600);
var asSeenInConsole = NodeUtil.inspect(cache);
// we're trying not to depend on what a given version of node actually serializes
var itemsDumped = /6 => 'f',\s*5 => 'e',\s*1 => 'a',(.|\n)+1168 => 1168,\s*'_\.\.\.' => 581,\s*'oldest' => 'd'/;
assert.deepStrictEqual(itemsDumped.test(asSeenInConsole), true);
assert.deepStrictEqual(new RegExp(`${name}:\[600/600\]`).test(asSeenInConsole), true);
assert.deepStrictEqual(asSeenInConsole.length < 400, true);
});

it('puts a reasonable limit on what the console will show (small)', function () {
var cache = makeExercisedCache(7);
var asSeenInConsole = NodeUtil.inspect(cache);
var itemsDumped = /6 => 'f',\s*5 => 'e',\s*1 => 'a',\s*2 => 'B',\s*4 => 'D',\s*0 => 0,\s*'oldest' => 'd'/;
assert.deepStrictEqual(itemsDumped.test(asSeenInConsole), true);
assert.deepStrictEqual(new RegExp(`${name}:7/7`).test(asSeenInConsole), true);
});

it('listens to advice about maximum inspection depth', function () {
var cache = makeExercisedCache(7);
var asSeenInConsole = NodeUtil.inspect({foo: {bar: cache}});
var itemsDumped = /6 => 'f',\s*5 => 'e',\s*1 => 'a',\s*2 => 'B',\s*4 => 'D',\s*0 => 0,\s*'oldest' => 'd'/;
assert.deepStrictEqual(itemsDumped.test(asSeenInConsole), true);
assert.deepStrictEqual(asSeenInConsole.length > 150, true);
asSeenInConsole = NodeUtil.inspect({foo: {bar: [cache]}});
assert.deepStrictEqual(asSeenInConsole.length < 70, true);
assert.deepStrictEqual(new RegExp(`\\[ \\[object ${name}:7/7\\] \\]`).test(asSeenInConsole), true);
});

it('allows inspection of the raw item if "all" is given', function () {
var cache = makeExercisedCache(250);
var inspected = cache.inspect({maxToDump: 8, all: true});
var kk;
for (kk of ['items', 'K', 'V', 'size', 'capacity']) {
assert.deepStrictEqual(inspected[kk] === cache[kk], true);
}
assert.deepStrictEqual(Object.keys(cache), Object.keys(inspected));
});

});

});
}

Expand Down
24 changes: 24 additions & 0 deletions utils/snip.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@

function snipToLast(iterator, proxy, {maxToDump = 20, size = Infinity, last = []}) {
var step;

var ii = 0;
while ((step = iterator.next(), !step.done)) {
if (ii >= maxToDump - 2) {
if (ii >= size - 1) {
proxy.set(step.value[0], step.value[1]);
} else if (ii === size - 2) {
proxy.set(step.value[0], step.value[1]);
proxy.set(last[0], last[1]);
} else {
proxy.set('_...', size - ii - 1);
proxy.set(last[0], last[1]);
}
break;
}
proxy.set(step.value[0], step.value[1]);
ii += 1;
}
}

module.exports.snipToLast = snipToLast;

0 comments on commit d858639

Please sign in to comment.