From 22caa0ee6669a57afe6ded31af855ffa0ca8ff53 Mon Sep 17 00:00:00 2001 From: Julien Fontanet Date: Mon, 27 Apr 2015 15:00:18 +0200 Subject: [PATCH] Initial Index implementation (see #1). --- packages/xo-collection/src/collection.js | 50 ++++++- packages/xo-collection/src/index.js | 157 +++++++++++++++++++++ packages/xo-collection/src/index.spec.js | 165 +++++++++++++++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 packages/xo-collection/src/index.js create mode 100644 packages/xo-collection/src/index.spec.js diff --git a/packages/xo-collection/src/collection.js b/packages/xo-collection/src/collection.js index dc82f006a..c5f66eb1e 100644 --- a/packages/xo-collection/src/collection.js +++ b/packages/xo-collection/src/collection.js @@ -13,6 +13,8 @@ function isNotEmpty (map) { return false } +const {hasOwnProperty} = Object + // =================================================================== export class BufferAlreadyFlushed extends BaseError { @@ -21,6 +23,12 @@ export class BufferAlreadyFlushed extends BaseError { } } +export class DuplicateIndex extends BaseError { + constructor (name) { + super('there is already an index with the name ' + name) + } +} + export class DuplicateItem extends BaseError { constructor (key) { super('there is already a item with the key ' + key) @@ -39,6 +47,12 @@ export class InvalidKey extends BaseError { } } +export class NoSuchIndex extends BaseError { + constructor (key) { + super('there is no index with the name ' + name) + } +} + export class NoSuchItem extends BaseError { constructor (key) { super('there is no item with the key ' + key) @@ -53,6 +67,8 @@ export default class Collection extends EventEmitter { this._buffer = Object.create(null) this._buffering = 0 + this._indexes = Object.create(null) + this._indexedItems = Object.create(null) this._items = Object.create(null) this._size = 0 } @@ -73,6 +89,10 @@ export default class Collection extends EventEmitter { return this._items } + get indexes () { + return this._indexedItems + } + get size () { return this._size } @@ -157,7 +177,35 @@ export default class Collection extends EventEmitter { } has (key) { - return Object.hasOwnProperty.call(this._items, key) + return hasOwnProperty.call(this._items, key) + } + + // ----------------------------------------------------------------- + // Indexes + // ----------------------------------------------------------------- + + createIndex (name, index) { + index._attachCollection(this) + + const {_indexes: indexes} = this + if (hasOwnProperty.call(indexes, name)) { + throw new DuplicateIndex(name) + } + indexes[name] = index + this._indexedItems[name] = index.itemsByHash + } + + deleteIndex (name) { + const {_indexes: indexes} = this + if (!hasOwnProperty.call(indexes, name)) { + throw new NoSuchIndex(name) + } + + const index = indexes[name] + delete indexes[name] + delete this._indexedItems[name] + + index._detachCollection() } // ----------------------------------------------------------------- diff --git a/packages/xo-collection/src/index.js b/packages/xo-collection/src/index.js new file mode 100644 index 000000000..a4741d557 --- /dev/null +++ b/packages/xo-collection/src/index.js @@ -0,0 +1,157 @@ +import bind from 'lodash.bind' +import {BaseError} from 'make-error' + +// =================================================================== + +class NotImplemented extends BaseError { + constructor (message) { + super(message || 'this method is not implemented') + } +} + +// =================================================================== + +const clearObject = (object) => { + for (let key in object) { + delete object[key] + } +} + +const isEmpty = (object) => { + /* eslint no-unused-vars: 0 */ + for (let key in object) { + return false + } + return true +} + +// =================================================================== + +export default class Index { + constructor () { + this._itemsByHash = 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') + } + + // Remove empty items lists. + sweep () { + const {_itemsByHash: itemsByHash} = this + for (let hash in itemsByHash) { + if (isEmpty(itemsByHash[hash])) { + delete itemsByHash[hash] + } + } + } + + // ----------------------------------------------------------------- + + get itemsByHash () { + return this._itemsByHash + } + + // ----------------------------------------------------------------- + + _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._hashes) + } + + // ----------------------------------------------------------------- + + _onAdd (items) { + const { + computeHash, + _itemsByHash: itemsByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const value = items[key] + + const hash = computeHash(value, key) + + if (hash != null) { + ( + itemsByHash[hash] || + (itemsByHash[hash] = Object.create(null)) + )[key] = value + + keysToHash[key] = hash + } + } + } + + _onUpdate (items) { + const { + computeHash, + _itemsByHash: itemsByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const value = items[key] + + const prev = keysToHash[key] + const hash = computeHash(value, key) + if (hash === prev) { + continue + } + + if (prev != null) { + delete itemsByHash[prev][key] + } + + if (hash != null) { + ( + itemsByHash[hash] || + (itemsByHash[hash] = Object.create(null)) + )[key] = value + + keysToHash[key] = hash + } else { + delete keysToHash[key] + } + } + } + + _onRemove (items) { + const { + _itemsByHash: itemsByHash, + _keysToHash: keysToHash + } = this + + for (let key in items) { + const prev = keysToHash[key] + if (prev != null) { + delete keysToHash[key] + delete itemsByHash[prev][key] + } + } + } +} diff --git a/packages/xo-collection/src/index.spec.js b/packages/xo-collection/src/index.spec.js new file mode 100644 index 000000000..0534784fe --- /dev/null +++ b/packages/xo-collection/src/index.spec.js @@ -0,0 +1,165 @@ +/* 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 './collection' +import Index from './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('Index', function () { + let col, byGroup + const item1 = { + id: '2ccb8a72-dc65-48e4-88fe-45ef541f2cba', + group: 'foo' + } + const item2 = { + id: '7d21dc51-4da8-4538-a2e9-dd6f4784eb76', + group: 'bar' + } + const item3 = { + id: '668c1274-4442-44a6-b99a-512188e0bb09', + group: 'foo' + } + const item4 = { + id: 'd90b7335-e540-4a44-ad22-c4baae9cd0a9' + } + + beforeEach(function () { + col = new Collection() + forEach([item1, item2, item3, item4], item => { + col.add(item) + }) + + byGroup = new Index() + byGroup.computeHash = item => item.group + + col.createIndex('byGroup', byGroup) + + return waitTicks() + }) + + it('works with existing items', function () { + expect(col.indexes).to.eql({ + byGroup: { + foo: { + [item1.id]: item1, + [item3.id]: item3 + }, + bar: { + [item2.id]: item2 + } + } + }) + }) + + it('works with added items', function () { + const item5 = { + id: '823b56c4-4b96-4f3a-9533-5d08177167ac', + group: 'baz' + } + + col.add(item5) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byGroup: { + foo: { + [item1.id]: item1, + [item3.id]: item3 + }, + bar: { + [item2.id]: item2 + }, + baz: { + [item5.id]: item5 + } + } + }) + }) + }) + + it('works with updated items', function () { + const item1bis = { + id: item1.id, + group: 'bar' + } + + col.update(item1bis) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byGroup: { + foo: { + [item3.id]: item3 + }, + bar: { + [item1.id]: item1bis, + [item2.id]: item2 + } + } + }) + }) + }) + + it('works with removed items', function () { + col.remove(item2) + + return waitTicks(2).then(() => { + expect(col.indexes).to.eql({ + byGroup: { + foo: { + [item1.id]: item1, + [item3.id]: item3 + }, + bar: {} + } + }) + }) + }) + + describe('#sweep()', function () { + it('removes empty items lists', function () { + col.remove(item2) + + return waitTicks(2).then(() => { + byGroup.sweep() + + expect(col.indexes).to.eql({ + byGroup: { + foo: { + [item1.id]: item1, + [item3.id]: item3 + } + } + }) + }) + }) + }) +})