From 41d546443629c5fa22d086e48335165a60ca01f7 Mon Sep 17 00:00:00 2001 From: "Philip (flip) Kromer" Date: Sat, 6 Aug 2022 19:19:07 -0500 Subject: [PATCH] feat: LRUCache/LRUMap family -- inspect and toString improvements * 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 #129). --- lru-cache-with-delete.js | 14 ++++- lru-cache.js | 51 +++++++++++++---- lru-map-with-delete.js | 14 ++++- lru-map.js | 19 +++++-- test/lru-cache.js | 120 ++++++++++++++++++++++++++++++++++++++- utils/snip.js | 24 ++++++++ 6 files changed, 223 insertions(+), 19 deletions(-) create mode 100644 utils/snip.js diff --git a/lru-cache-with-delete.js b/lru-cache-with-delete.js index b0fd12e3..09cf9587 100644 --- a/lru-cache-with-delete.js +++ b/lru-cache-with-delete.js @@ -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. diff --git a/lru-cache.js b/lru-cache.js index d87a4d4e..5d1e2277 100644 --- a/lru-cache.js +++ b/lru-cache.js @@ -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. @@ -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; }; diff --git a/lru-map-with-delete.js b/lru-map-with-delete.js index 0e58fa76..0ee89f44 100644 --- a/lru-map-with-delete.js +++ b/lru-map-with-delete.js @@ -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. diff --git a/lru-map.js b/lru-map.js index 87f7aa38..4c065fcf 100644 --- a/lru-map.js +++ b/lru-map.js @@ -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 diff --git a/test/lru-cache.js b/test/lru-cache.js index 2cfc6afa..8ee5b9b4 100644 --- a/test/lru-cache.js +++ b/test/lru-cache.js @@ -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('util'); function makeTests(Cache, name) { describe(name, function() { @@ -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); @@ -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); @@ -302,6 +303,7 @@ function makeTests(Cache, name) { assert.equal(dead, missingMarker); cache.set('one', 'uno'); + cache.set('two', 'dos'); cache.set('three', 'tres'); @@ -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)); + }); + + }); + }); } diff --git a/utils/snip.js b/utils/snip.js new file mode 100644 index 00000000..509a253e --- /dev/null +++ b/utils/snip.js @@ -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;