From a10b5fdde8f1ac3232e9b83e4c9e659617141922 Mon Sep 17 00:00:00 2001 From: Alec Hirsch Date: Tue, 20 Aug 2019 10:59:56 -0400 Subject: [PATCH 1/5] added performance boost to convert case --- benchmark/index.js | 47 +++++++++++++++++++++++++++++----------- lib/JSONAPISerializer.js | 25 ++++++++++++++++++--- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/benchmark/index.js b/benchmark/index.js index 45700e5..6db13da 100644 --- a/benchmark/index.js +++ b/benchmark/index.js @@ -9,6 +9,10 @@ const JSONAPISerializer = require('..'); const suite = new Benchmark.Suite(); const serializer = new JSONAPISerializer(); +const serializerConvert = new JSONAPISerializer({ + convertCase: 'kebab-case', + unconvertCase: 'camelCase' +}); const data = [ { @@ -51,8 +55,8 @@ const data = [ } ]; -serializer.register('article', { - id: 'id', // The attributes to use as the reference. Default = 'id'. +const articleSchema = { + id: 'id', links: { // An object or a function that describes links. self(d) { @@ -63,7 +67,7 @@ serializer.register('article', { relationships: { // An object defining some relationships. author: { - type: 'people', // The type of the resource + type: 'people', links(d) { // An object or a function that describes Relationships links return { @@ -92,36 +96,47 @@ serializer.register('article', { }, topLevelLinks: { // An object or a function that describes top level links. - self: '/articles' // Can be a function (with extra data argument) or a string value + self: '/articles' } -}); +}; +serializer.register('article', articleSchema); +serializerConvert.register('article', articleSchema); // Register 'people' type -serializer.register('people', { +const peopleSchema = { id: 'id', links: { self(d) { return `/peoples/${d.id}`; } } -}); +}; +serializer.register('people', peopleSchema); +serializerConvert.register('people', peopleSchema); // Register 'tag' type -serializer.register('tag', { +const tagSchema = { id: 'id' -}); +}; +serializer.register('tag', tagSchema); +serializerConvert.register('tag', tagSchema); // Register 'photo' type -serializer.register('photo', { +const photoSchema = { id: 'id' -}); +}; +serializer.register('photo', photoSchema); +serializerConvert.register('photo', photoSchema); // Register 'comment' type with a custom schema -serializer.register('comment', 'only-body', { +const commentSchema = { id: '_id' -}); +}; +serializer.register('comment', 'only-body', commentSchema); +serializerConvert.register('comment', 'only-body', commentSchema); let serialized; +let serializedConvert; // Plateform console.log('Platform info:'); @@ -160,6 +175,9 @@ suite .add('serialize', () => { serialized = serializer.serialize('article', data, { count: 2 }); }) + .add('serializeConvertCase', () => { + serializedConvert = serializerConvert.serialize('article', data, { count: 2 }); + }) .add('deserializeAsync', { defer: true, fn(deferred) { @@ -171,6 +189,9 @@ suite .add('deserialize', () => { serializer.deserialize('article', serialized); }) + .add('deserializeConvertCase', () => { + serializerConvert.deserialize('article', serializedConvert); + }) .add('serializeError', () => { const error = new Error('An error occured'); error.status = 500; diff --git a/lib/JSONAPISerializer.js b/lib/JSONAPISerializer.js index e0c44bd..ca58f07 100644 --- a/lib/JSONAPISerializer.js +++ b/lib/JSONAPISerializer.js @@ -13,6 +13,13 @@ const { const { validateOptions, validateDynamicTypeOptions, validateError } = require('./validator'); +// Cache of strings to convert to their converted values per conversion type +const convertCaseMap = { + camelCase: {}, + kebabCase: {}, + snakeCase: {} +}; + /** * JSONAPISerializer class. * @@ -794,13 +801,25 @@ module.exports = class JSONAPISerializer { switch (convertCaseOptions) { case 'snake_case': - converted = toSnakeCase(data); + converted = convertCaseMap.snakeCase[data]; + if (!converted) { + converted = toSnakeCase(data); + convertCaseMap.snakeCase[data] = converted; + } break; case 'kebab-case': - converted = toKebabCase(data); + converted = convertCaseMap.kebabCase[data]; + if (!converted) { + converted = toKebabCase(data); + convertCaseMap.kebabCase[data] = converted; + } break; case 'camelCase': - converted = toCamelCase(data); + converted = convertCaseMap.camelCase[data]; + if (!converted) { + converted = toCamelCase(data); + convertCaseMap.camelCase[data] = converted; + } break; default: // Do nothing } From 6c4652a5a48664006d6bcb3cef484d63b79228fd Mon Sep 17 00:00:00 2001 From: Alec Hirsch Date: Wed, 21 Aug 2019 13:03:19 -0400 Subject: [PATCH 2/5] added LRU caching mechanism --- README.md | 4 +- lib/JSONAPISerializer.js | 32 ++++---- lib/helpers.js | 4 +- lib/lru-cache.js | 109 ++++++++++++++++++++++++++++ test/unit/JSONAPISerializer.test.js | 107 ++++++++++++++++++++++++++- 5 files changed, 237 insertions(+), 19 deletions(-) create mode 100644 lib/lru-cache.js diff --git a/README.md b/README.md index a330fe5..101544d 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,14 @@ Serializer.register(type, options); To avoid repeating the same options for each type, it's possible to add global options on `JSONAPISerializer` instance: +When using convertCase, a LRU cache is utilized for optimization. The default size of the cache is 5000 per conversion type. The size of the cache can be set by passing in a second parameter to the instance. Passing in 0 will result in a LRU cache of infinite size. + ```javascript var JSONAPISerializer = require("json-api-serializer"); var Serializer = new JSONAPISerializer({ convertCase: "kebab-case", unconvertCase: "camelCase" -}); +}, 0); ``` ## Usage diff --git a/lib/JSONAPISerializer.js b/lib/JSONAPISerializer.js index ca58f07..dde708a 100644 --- a/lib/JSONAPISerializer.js +++ b/lib/JSONAPISerializer.js @@ -8,18 +8,12 @@ const { set, toCamelCase, toKebabCase, - toSnakeCase + toSnakeCase, + LRU } = require('./helpers'); const { validateOptions, validateDynamicTypeOptions, validateError } = require('./validator'); -// Cache of strings to convert to their converted values per conversion type -const convertCaseMap = { - camelCase: {}, - kebabCase: {}, - snakeCase: {} -}; - /** * JSONAPISerializer class. * @@ -31,11 +25,19 @@ const convertCaseMap = { * * @class JSONAPISerializer * @param {object} [opts] Global options. + * @param {integer} [convertCaseCacheSize=5000] Size of cache used for convertCase, 0 results in an infinitely sized cache */ module.exports = class JSONAPISerializer { - constructor(opts) { + constructor(opts, convertCaseCacheSize = 5000) { this.opts = opts || {}; this.schemas = {}; + + // Cache of strings to convert to their converted values per conversion type + this.convertCaseMap = { + camelCase: new LRU(convertCaseCacheSize), + kebabCase: new LRU(convertCaseCacheSize), + snakeCase: new LRU(convertCaseCacheSize) + }; } /** @@ -801,24 +803,24 @@ module.exports = class JSONAPISerializer { switch (convertCaseOptions) { case 'snake_case': - converted = convertCaseMap.snakeCase[data]; + converted = this.convertCaseMap.snakeCase.get(data); if (!converted) { converted = toSnakeCase(data); - convertCaseMap.snakeCase[data] = converted; + this.convertCaseMap.snakeCase.set(data, converted); } break; case 'kebab-case': - converted = convertCaseMap.kebabCase[data]; + converted = this.convertCaseMap.kebabCase.get(data); if (!converted) { converted = toKebabCase(data); - convertCaseMap.kebabCase[data] = converted; + this.convertCaseMap.kebabCase.set(data, converted); } break; case 'camelCase': - converted = convertCaseMap.camelCase[data]; + converted = this.convertCaseMap.camelCase.get(data); if (!converted) { converted = toCamelCase(data); - convertCaseMap.camelCase[data] = converted; + this.convertCaseMap.camelCase.set(data, converted); } break; default: // Do nothing diff --git a/lib/helpers.js b/lib/helpers.js index 7fe6dea..7cfafd4 100644 --- a/lib/helpers.js +++ b/lib/helpers.js @@ -9,6 +9,7 @@ const { toCamelCase } = require('30-seconds-of-code'); const set = require('lodash.set'); +const LRU = require('./lru-cache'); // https://github.com/you-dont-need/You-Dont-Need-Lodash-Underscore#_get const get = (obj, path, defaultValue) => @@ -27,5 +28,6 @@ module.exports = { transform, toKebabCase, toSnakeCase, - toCamelCase + toCamelCase, + LRU }; diff --git a/lib/lru-cache.js b/lib/lru-cache.js new file mode 100644 index 0000000..3e3588e --- /dev/null +++ b/lib/lru-cache.js @@ -0,0 +1,109 @@ +// Influenced by http://jsfiddle.net/2baax9nk/5/ + +class Node { + constructor(key, data) { + this.key = key; + this.data = data; + this.previous = null; + this.next = null; + } +} + +module.exports = class LRU { + constructor(capacity) { + this.capacity = capacity === 0 ? Infinity : capacity; + this.map = {}; + this.head = null; + this.tail = null; + } + + get(key) { + // Existing item + if (this.map[key] !== undefined) { + // Move to the first place + const node = this.map[key]; + this._moveFirst(node); + + // Return + return node.data; + } + + // Not found + return undefined; + } + + set(key, value) { + // Existing item + if (this.map[key] !== undefined) { + // Move to the first place + const node = this.map[key]; + node.data = value; + this._moveFirst(node); + return; + } + + // Ensuring the cache is within capacity + if (Object.keys(this.map).length >= this.capacity) { + const id = this.tail.key; + this._removeLast(); + delete this.map[id]; + } + + // New Item + const node = new Node(key, value); + this._add(node); + this.map[key] = node; + } + + _add(node) { + node.next = null; + node.previous = node.next; + + // first item + if (this.head === null) { + this.head = node; + this.tail = node; + } else { + // adding to existing items + this.head.previous = node; + node.next = this.head; + this.head = node; + } + } + + _remove(node) { + // only item in the cache + if (this.head === node && this.tail === node) { + this.tail = null; + this.head = this.tail; + return; + } + + // remove from head + if (this.head === node) { + this.head.next.previous = null; + this.head = this.head.next; + return; + } + + // remove from tail + if (this.tail === node) { + this.tail.previous.next = null; + this.tail = this.tail.previous; + return; + } + + // remove from middle + node.previous.next = node.next; + node.next.previous = node.previous; + } + + _moveFirst(node) { + this._remove(node); + this._add(node); + } + + _removeLast() { + this._remove(this.tail); + } +}; diff --git a/test/unit/JSONAPISerializer.test.js b/test/unit/JSONAPISerializer.test.js index 41e87d2..2a2a878 100644 --- a/test/unit/JSONAPISerializer.test.js +++ b/test/unit/JSONAPISerializer.test.js @@ -8,6 +8,7 @@ const TickCounter = require('../helpers/tick-counter'); const JSONAPISerializer = require('../../'); const validator = require('../../lib/validator'); +const LRU = require('../../lib/lru-cache'); describe('JSONAPISerializer', function() { describe('register', function() { @@ -2154,5 +2155,107 @@ describe('JSONAPISerializer', function() { expect(converted['array-of-number']).to.deep.equal([1, 2, 3, 4, 5]); expect(converted.date).to.be.a('Date'); }); - }); -}); + }); + + describe('LRU Cache', function() { + it('should create an LRU', function() { + let lru = new LRU(5); + expect(lru).to.have.property('head'); + expect(lru).to.have.property('tail'); + expect(lru.capacity).to.equal(5); + }); + + it('should set a single node, and be able to retreive it', function() { + let lru = new LRU(5); + lru.set('myKey', 'my-key'); + + expect(lru.head.data).to.equal('my-key'); + expect(lru.head.previous).to.equal(null); + expect(lru.head.next).to.equal(null); + + let myKey = lru.get('myKey'); + expect(myKey).to.equal('my-key'); + }) + + it('should add new nodes to the head and move last fetched node to the head', function() { + let lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + lru.set(4, 4); + + let head = lru.head; + expect(head.previous).to.equal(null); + expect(head.data).to.equal(4); + expect(head.next.data).to.equal(3); + expect(head.next.next.data).to.equal(2); + expect(head.next.next.next.data).to.equal(1); + expect(head.next.next.next.next).to.equal(null); + + let result = lru.get(2); + head = lru.head; + expect(result).to.equal(2); + expect(head.previous).to.equal(null); + expect(head.data).to.equal(2); + expect(head.next.data).to.equal(4); + expect(head.next.next.data).to.equal(3); + expect(head.next.next.next.data).to.equal(1); + expect(head.next.next.next.next).to.equal(null); + }) + + it('should remove nodes after hitting capacity', function() { + let lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + lru.set(4, 4); + lru.set(5, 5); + lru.get(1); + lru.set(6, 6); + + let head = lru.head; + expect(head.previous).to.equal(null); + expect(head.data).to.equal(6); + expect(head.next.data).to.equal(1); + expect(head.next.next.data).to.equal(5); + expect(head.next.next.next.data).to.equal(4); + expect(head.next.next.next.next.data).to.equal(3); + expect(head.next.next.next.next.next).to.equal(null); + }) + + it('should create an LRU of infinite capacity', function() { + let lru = new LRU(0); + + expect(lru.capacity).to.equal(Infinity); + }) + + it('should replace a node if the capacity is 1', function() { + let lru = new LRU(1); + + lru.set(1, 1); + lru.set(2, 2); + + let head = lru.head; + expect(head.data).to.equal(2); + expect(head.previous).to.equal(null); + expect(head.next).to.equal(null); + + expect(lru.get(1)).to.equal(undefined); + expect(lru.get(2)).to.equal(2); + }) + + it('should reset a nodes value if it already exists', function() { + let lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + + lru.set(1, 10); + + console.log(lru); + + expect(lru.head.data).to.equal(10); + expect(lru.get(1)).to.equal(10); + }) + }); +}); \ No newline at end of file From be5a58d7625211fa3b23f4888133e1101c807211 Mon Sep 17 00:00:00 2001 From: Alec Hirsch Date: Wed, 21 Aug 2019 15:17:49 -0400 Subject: [PATCH 3/5] fixed param type --- lib/JSONAPISerializer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/JSONAPISerializer.js b/lib/JSONAPISerializer.js index dde708a..6225ebd 100644 --- a/lib/JSONAPISerializer.js +++ b/lib/JSONAPISerializer.js @@ -25,7 +25,7 @@ const { validateOptions, validateDynamicTypeOptions, validateError } = require(' * * @class JSONAPISerializer * @param {object} [opts] Global options. - * @param {integer} [convertCaseCacheSize=5000] Size of cache used for convertCase, 0 results in an infinitely sized cache + * @param {number} [convertCaseCacheSize=5000] Size of cache used for convertCase, 0 results in an infinitely sized cache */ module.exports = class JSONAPISerializer { constructor(opts, convertCaseCacheSize = 5000) { From a67876063ffca2cf925efad49d88580be8321ef0 Mon Sep 17 00:00:00 2001 From: Alec Hirsch Date: Thu, 22 Aug 2019 09:33:38 -0400 Subject: [PATCH 4/5] lru tests in its own file --- test/unit/JSONAPISerializer.test.js | 101 --------------------------- test/unit/lru-cache.test.js | 104 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 101 deletions(-) create mode 100644 test/unit/lru-cache.test.js diff --git a/test/unit/JSONAPISerializer.test.js b/test/unit/JSONAPISerializer.test.js index 2a2a878..5abd0aa 100644 --- a/test/unit/JSONAPISerializer.test.js +++ b/test/unit/JSONAPISerializer.test.js @@ -2156,106 +2156,5 @@ describe('JSONAPISerializer', function() { expect(converted.date).to.be.a('Date'); }); }); - - describe('LRU Cache', function() { - it('should create an LRU', function() { - let lru = new LRU(5); - expect(lru).to.have.property('head'); - expect(lru).to.have.property('tail'); - expect(lru.capacity).to.equal(5); - }); - - it('should set a single node, and be able to retreive it', function() { - let lru = new LRU(5); - lru.set('myKey', 'my-key'); - - expect(lru.head.data).to.equal('my-key'); - expect(lru.head.previous).to.equal(null); - expect(lru.head.next).to.equal(null); - - let myKey = lru.get('myKey'); - expect(myKey).to.equal('my-key'); - }) - - it('should add new nodes to the head and move last fetched node to the head', function() { - let lru = new LRU(5); - lru.set(1, 1); - lru.set(2, 2); - lru.set(3, 3); - lru.set(4, 4); - - let head = lru.head; - expect(head.previous).to.equal(null); - expect(head.data).to.equal(4); - expect(head.next.data).to.equal(3); - expect(head.next.next.data).to.equal(2); - expect(head.next.next.next.data).to.equal(1); - expect(head.next.next.next.next).to.equal(null); - - let result = lru.get(2); - head = lru.head; - expect(result).to.equal(2); - expect(head.previous).to.equal(null); - expect(head.data).to.equal(2); - expect(head.next.data).to.equal(4); - expect(head.next.next.data).to.equal(3); - expect(head.next.next.next.data).to.equal(1); - expect(head.next.next.next.next).to.equal(null); - }) - - it('should remove nodes after hitting capacity', function() { - let lru = new LRU(5); - lru.set(1, 1); - lru.set(2, 2); - lru.set(3, 3); - lru.set(4, 4); - lru.set(5, 5); - lru.get(1); - lru.set(6, 6); - - let head = lru.head; - expect(head.previous).to.equal(null); - expect(head.data).to.equal(6); - expect(head.next.data).to.equal(1); - expect(head.next.next.data).to.equal(5); - expect(head.next.next.next.data).to.equal(4); - expect(head.next.next.next.next.data).to.equal(3); - expect(head.next.next.next.next.next).to.equal(null); - }) - - it('should create an LRU of infinite capacity', function() { - let lru = new LRU(0); - - expect(lru.capacity).to.equal(Infinity); - }) - - it('should replace a node if the capacity is 1', function() { - let lru = new LRU(1); - - lru.set(1, 1); - lru.set(2, 2); - - let head = lru.head; - expect(head.data).to.equal(2); - expect(head.previous).to.equal(null); - expect(head.next).to.equal(null); - - expect(lru.get(1)).to.equal(undefined); - expect(lru.get(2)).to.equal(2); - }) - it('should reset a nodes value if it already exists', function() { - let lru = new LRU(5); - lru.set(1, 1); - lru.set(2, 2); - lru.set(3, 3); - - lru.set(1, 10); - - console.log(lru); - - expect(lru.head.data).to.equal(10); - expect(lru.get(1)).to.equal(10); - }) - }); }); \ No newline at end of file diff --git a/test/unit/lru-cache.test.js b/test/unit/lru-cache.test.js new file mode 100644 index 0000000..015a395 --- /dev/null +++ b/test/unit/lru-cache.test.js @@ -0,0 +1,104 @@ +/* eslint-disable func-names */ +const { expect } = require('chai'); + +const LRU = require('../../lib/lru-cache'); + +describe('LRU Cache', function() { + it('should create an LRU', function() { + const lru = new LRU(5); + expect(lru).to.have.property('head'); + expect(lru).to.have.property('tail'); + expect(lru.capacity).to.equal(5); + }); + + it('should set a single node, and be able to retreive it', function() { + const lru = new LRU(5); + lru.set('myKey', 'my-key'); + + expect(lru.head.data).to.equal('my-key'); + expect(lru.head.previous).to.equal(null); + expect(lru.head.next).to.equal(null); + + const myKey = lru.get('myKey'); + expect(myKey).to.equal('my-key'); + }); + + it('should add new nodes to the head and move last fetched node to the head', function() { + const lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + lru.set(4, 4); + + let { head } = lru; + expect(head.previous).to.equal(null); + expect(head.data).to.equal(4); + expect(head.next.data).to.equal(3); + expect(head.next.next.data).to.equal(2); + expect(head.next.next.next.data).to.equal(1); + expect(head.next.next.next.next).to.equal(null); + + const result = lru.get(2); + ({ head } = lru); + expect(result).to.equal(2); + expect(head.previous).to.equal(null); + expect(head.data).to.equal(2); + expect(head.next.data).to.equal(4); + expect(head.next.next.data).to.equal(3); + expect(head.next.next.next.data).to.equal(1); + expect(head.next.next.next.next).to.equal(null); + }); + + it('should remove nodes after hitting capacity', function() { + const lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + lru.set(4, 4); + lru.set(5, 5); + lru.get(1); + lru.set(6, 6); + + const { head } = lru; + expect(head.previous).to.equal(null); + expect(head.data).to.equal(6); + expect(head.next.data).to.equal(1); + expect(head.next.next.data).to.equal(5); + expect(head.next.next.next.data).to.equal(4); + expect(head.next.next.next.next.data).to.equal(3); + expect(head.next.next.next.next.next).to.equal(null); + }); + + it('should create an LRU of infinite capacity', function() { + const lru = new LRU(0); + + expect(lru.capacity).to.equal(Infinity); + }); + + it('should replace a node if the capacity is 1', function() { + const lru = new LRU(1); + + lru.set(1, 1); + lru.set(2, 2); + + const { head } = lru; + expect(head.data).to.equal(2); + expect(head.previous).to.equal(null); + expect(head.next).to.equal(null); + + expect(lru.get(1)).to.equal(undefined); + expect(lru.get(2)).to.equal(2); + }); + + it('should reset a nodes value if it already exists', function() { + const lru = new LRU(5); + lru.set(1, 1); + lru.set(2, 2); + lru.set(3, 3); + + lru.set(1, 10); + + expect(lru.head.data).to.equal(10); + expect(lru.get(1)).to.equal(10); + }); +}); From 949271196c482225d319872fdf6bd81b25ceae7c Mon Sep 17 00:00:00 2001 From: Alec Hirsch Date: Thu, 22 Aug 2019 10:03:23 -0400 Subject: [PATCH 5/5] removed LRU require from main test --- test/unit/JSONAPISerializer.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/unit/JSONAPISerializer.test.js b/test/unit/JSONAPISerializer.test.js index 5abd0aa..77d510e 100644 --- a/test/unit/JSONAPISerializer.test.js +++ b/test/unit/JSONAPISerializer.test.js @@ -8,7 +8,6 @@ const TickCounter = require('../helpers/tick-counter'); const JSONAPISerializer = require('../../'); const validator = require('../../lib/validator'); -const LRU = require('../../lib/lru-cache'); describe('JSONAPISerializer', function() { describe('register', function() { @@ -2156,5 +2155,4 @@ describe('JSONAPISerializer', function() { expect(converted.date).to.be.a('Date'); }); }); - -}); \ No newline at end of file +});