diff --git a/packages/xo-collection/src/unique-index.js b/packages/xo-collection/src/unique-index.js new file mode 100644 index 000000000..36e6cb13f --- /dev/null +++ b/packages/xo-collection/src/unique-index.js @@ -0,0 +1,123 @@ +import bind from 'lodash.bind' +import callback from 'lodash.callback' + +import clearObject from './clear-object' +import NotImplemented from './not-implemented' + +// =================================================================== + +export default class UniqueIndex { + constructor (computeHash) { + if (computeHash) { + this.computeHash = callback(computeHash) + } + + this._itemByHash = Object.create(null) + this._keysToHash = Object.create(null) + + // Bound versions of listeners. + this._onAdd = bind(this._onAdd, this) + this._onUpdate = bind(this._onUpdate, this) + this._onRemove = bind(this._onRemove, this) + } + + // This method is used to compute the hash under which an item must + // be saved. + computeHash (value, key) { + throw new NotImplemented('this method must be overridden') + } + + // ----------------------------------------------------------------- + + get items () { + return this._itemByHash + } + + // ----------------------------------------------------------------- + + _attachCollection (collection) { + // Add existing entries. + // + // FIXME: I think there may be a race condition if the `add` event + // has not been emitted yet. + this._onAdd(collection.all) + + collection.on('add', this._onAdd) + collection.on('update', this._onUpdate) + collection.on('remove', this._onRemove) + } + + _detachCollection (collection) { + collection.removeListener('add', this._onAdd) + collection.removeListener('update', this._onUpdate) + collection.removeListener('remove', this._onRemove) + + clearObject(this._itemByHash) + clearObject(this._keysToHash) + } + + // ----------------------------------------------------------------- + + _onAdd (items) { + const { + computeHash, + _itemByHash: itemByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const value = items[key] + + const hash = computeHash(value, key) + + if (hash != null) { + itemByHash[hash] = {key, value} + keysToHash[key] = hash + } + } + } + + _onUpdate (items) { + const { + computeHash, + _itemByHash: itemByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const value = items[key] + + const prev = keysToHash[key] + const hash = computeHash(value, key) + + // Same hash, nothing to do. + if (hash === prev) continue + + // Removes item from the previous hash's list if any. + if (prev != null) delete itemByHash[prev] + + // Inserts item into the new hash's list if any. + if (hash != null) { + keysToHash[key] = hash + itemByHash[hash] = {key, value} + } else { + delete keysToHash[key] + } + } + } + + _onRemove (items) { + const { + _itemByHash: itemByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const prev = keysToHash[key] + if (prev != null) { + delete keysToHash[key] + delete itemByHash[prev] + } + } + } +} diff --git a/packages/xo-collection/src/unique-index.spec.js b/packages/xo-collection/src/unique-index.spec.js new file mode 100644 index 000000000..cc62fa4fd --- /dev/null +++ b/packages/xo-collection/src/unique-index.spec.js @@ -0,0 +1,144 @@ +/* eslint-env mocha */ + +import chai, {expect} from 'chai' +import dirtyChai from 'dirty-chai' +chai.use(dirtyChai) + +import sourceMapSupport from 'source-map-support' +sourceMapSupport.install() + +import forEach from 'lodash.foreach' + +// ------------------------------------------------------------------- + +import Collection from '..' +import Index from '../unique-index' + +// =================================================================== + +const waitTicks = (n = 1) => { + const {nextTick} = process + + return new Promise(resolve => { + (function waitNextTick () { + // The first tick is handled by Promise#then() + if (--n) { + nextTick(waitNextTick) + } else { + resolve() + } + })() + }) +} + +// =================================================================== + +describe('UniqueIndex', function () { + let col, byKey + const item1 = { + id: '2ccb8a72-dc65-48e4-88fe-45ef541f2cba', + key: '036dee1b-9a3b-4fb5-be8a-4f535b355581' + } + const item2 = { + id: '7d21dc51-4da8-4538-a2e9-dd6f4784eb76', + key: '103cd893-d2cc-4d37-96fd-c259ad04c0d4' + } + const item3 = { + id: '668c1274-4442-44a6-b99a-512188e0bb09' + } + + beforeEach(function () { + col = new Collection() + forEach([item1, item2, item3], item => { + col.add(item) + }) + + byKey = new Index('key') + + col.createIndex('byKey', byKey) + + return waitTicks() + }) + + it('works with existing items', function () { + expect(col.indexes).to.eql({ + byKey: { + [item1.key]: { + key: item1.id, + value: item1 + }, + [item2.key]: { + key: item2.id, + value: item2 + } + } + }) + }) + + it('works with added items', function () { + const item4 = { + id: '823b56c4-4b96-4f3a-9533-5d08177167ac', + key: '1437af14-429a-40db-8a51-8a2f5ed03201' + } + + col.add(item4) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byKey: { + [item1.key]: { + key: item1.id, + value: item1 + }, + [item2.key]: { + key: item2.id, + value: item2 + }, + [item4.key]: { + key: item4.id, + value: item4 + } + } + }) + }) + }) + + it('works with updated items', function () { + const item1bis = { + id: item1.id, + key: 'e03d4a3a-0331-4aca-97a2-016bbd43a29b' + } + + col.update(item1bis) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byKey: { + [item1bis.key]: { + key: item1.id, + value: item1bis + }, + [item2.key]: { + key: item2.id, + value: item2 + } + } + }) + }) + }) + + it('works with removed items', function () { + col.remove(item2) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byKey: { + [item1.key]: { + key: item1.id, + value: item1 + } + } + }) + }) + }) +}) diff --git a/packages/xo-collection/unique-index.js b/packages/xo-collection/unique-index.js new file mode 100644 index 000000000..9f9d92cd5 --- /dev/null +++ b/packages/xo-collection/unique-index.js @@ -0,0 +1 @@ +module.exports = require('./dist/unique-index')