Permalink
Please
sign in to comment.
Browse files
feat: Add new Datastore class
The Datastore class provides a persistent, database-backed key-value data storage class. This complements the existing Brain class in a few ways: 1. Each get/set operation is directly backed by the backing database, allowing multiple Hubot instances to share cooperative access to data simultaneously; 2. get/set operations are asynchronous, mapping well to the async access methods used by many database adapters.
- Loading branch information...
Showing
with
273 additions
and 2 deletions.
- +11 −0 docs/implementation.md
- +25 −2 docs/scripting.md
- +3 −0 es2015.js
- +94 −0 src/datastore.js
- +23 −0 src/datastores/memory.js
- +1 −0 src/robot.js
- +116 −0 test/datastore_test.js
| @@ -0,0 +1,94 @@ | |||
| 'use strict' | |||
|
|
|||
| class DataStore { | |||
| // Represents a persistent, database-backed storage for the robot. Extend this. | |||
| // | |||
| // Returns a new Datastore with no storage. | |||
| constructor (robot) { | |||
| this.robot = robot | |||
| } | |||
|
|
|||
| // Public: Set value for key in the database. Overwrites existing | |||
| // values if present. Returns a promise which resolves when the | |||
| // write has completed. | |||
| // | |||
| // Value can be any JSON-serializable type. | |||
| set (key, value) { | |||
| return this._set(key, value, 'global') | |||
| } | |||
|
|
|||
| // Public: Assuming `key` represents an object in the database, | |||
| // sets its `objectKey` to `value`. If `key` isn't already | |||
| // present, it's instantiated as an empty object. | |||
| setObject (key, objectKey, value) { | |||
| return this.get(key).then((object) => { | |||
| let target = object || {} | |||
| target[objectKey] = value | |||
| return this.set(key, target) | |||
| }) | |||
| } | |||
|
|
|||
| // Public: Adds the supplied value(s) to the end of the existing | |||
| // array in the database marked by `key`. If `key` isn't already | |||
| // present, it's instantiated as an empty array. | |||
| setArray (key, value) { | |||
| return this.get(key).then((object) => { | |||
| let target = object || [] | |||
| // Extend the array if the value is also an array, otherwise | |||
| // push the single value on the end. | |||
| if (Array.isArray(value)) { | |||
| return this.set(key, target.push.apply(target, value)) | |||
| } else { | |||
| return this.set(key, target.concat(value)) | |||
| } | |||
| }) | |||
| } | |||
|
|
|||
| // Public: Get value by key if in the database or return `undefined` | |||
| // if not found. Returns a promise which resolves to the | |||
| // requested value. | |||
| get (key) { | |||
| return this._get(key, 'global') | |||
| } | |||
|
|
|||
| // Public: Digs inside the object at `key` for a key named | |||
| // `objectKey`. If `key` isn't already present, or if it doesn't | |||
| // contain an `objectKey`, returns `undefined`. | |||
| getObject (key, objectKey) { | |||
| return this.get(key).then((object) => { | |||
| let target = object || {} | |||
| return target[objectKey] | |||
| }) | |||
| } | |||
|
|
|||
| // Private: Implements the underlying `set` logic for the datastore. | |||
| // This will be called by the public methods. This is one of two | |||
| // methods that must be implemented by subclasses of this class. | |||
| // `table` represents a unique namespace for this key, such as a | |||
| // table in a SQL database. | |||
| // | |||
| // This returns a resolved promise when the `set` operation is | |||
| // successful, and a rejected promise if the operation fails. | |||
| _set (key, value, table) { | |||
| return Promise.reject(new DataStoreUnavailable('Setter called on the abstract class.')) | |||
| } | |||
|
|
|||
| // Private: Implements the underlying `get` logic for the datastore. | |||
| // This will be called by the public methods. This is one of two | |||
| // methods that must be implemented by subclasses of this class. | |||
| // `table` represents a unique namespace for this key, such as a | |||
| // table in a SQL database. | |||
| // | |||
| // This returns a resolved promise containing the fetched value on | |||
| // success, and a rejected promise if the operation fails. | |||
| _get (key, table) { | |||
| return Promise.reject(new DataStoreUnavailable('Getter called on the abstract class.')) | |||
| } | |||
| } | |||
|
|
|||
| class DataStoreUnavailable extends Error {} | |||
|
|
|||
| module.exports = { | |||
| DataStore, | |||
| DataStoreUnavailable | |||
| } | |||
| @@ -0,0 +1,23 @@ | |||
| 'use strict' | |||
|
|
|||
| const DataStore = require('../datastore').DataStore | |||
|
|
|||
| class InMemoryDataStore extends DataStore { | |||
| constructor (robot) { | |||
| super(robot) | |||
| this.data = { | |||
| global: {}, | |||
| users: {} | |||
| } | |||
| } | |||
|
|
|||
| _get (key, table) { | |||
| return Promise.resolve(this.data[table][key]) | |||
| } | |||
|
|
|||
| _set (key, value, table) { | |||
| return Promise.resolve(this.data[table][key] = value) | |||
| } | |||
| } | |||
|
|
|||
| module.exports = InMemoryDataStore | |||
| @@ -0,0 +1,116 @@ | |||
| 'use strict' | |||
|
|
|||
| /* global describe, beforeEach, it */ | |||
|
|
|||
| const chai = require('chai') | |||
| const sinon = require('sinon') | |||
| chai.use(require('sinon-chai')) | |||
|
|
|||
| const expect = chai.expect | |||
|
|
|||
| const Brain = require('../src/brain') | |||
| const InMemoryDataStore = require('../src/datastores/memory') | |||
|
|
|||
| describe('Datastore', function () { | |||
| beforeEach(function () { | |||
| this.clock = sinon.useFakeTimers() | |||
| this.robot = { | |||
| emit () {}, | |||
| on () {}, | |||
| receive: sinon.spy() | |||
| } | |||
|
|
|||
| // This *should* be callsArgAsync to match the 'on' API, but that makes | |||
| // the tests more complicated and seems irrelevant. | |||
| sinon.stub(this.robot, 'on').withArgs('running').callsArg(1) | |||
|
|
|||
| this.robot.brain = new Brain(this.robot) | |||
| this.robot.datastore = new InMemoryDataStore(this.robot) | |||
| this.robot.brain.userForId('1', {name: 'User One'}) | |||
| this.robot.brain.userForId('2', {name: 'User Two'}) | |||
| }) | |||
|
|
|||
| describe('global scope', function () { | |||
| it('returns undefined for values not in the datastore', function () { | |||
| return this.robot.datastore.get('blah').then(function (value) { | |||
| expect(value).to.be.an('undefined') | |||
| }) | |||
| }) | |||
|
|
|||
| it('can store simple values', function () { | |||
| return this.robot.datastore.set('key', 'value').then(() => { | |||
| return this.robot.datastore.get('key').then((value) => { | |||
| expect(value).to.equal('value') | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('can store arbitrary JavaScript values', function () { | |||
| let object = { | |||
| 'name': 'test', | |||
| 'data': [1, 2, 3] | |||
| } | |||
| return this.robot.datastore.set('key', object).then(() => { | |||
| return this.robot.datastore.get('key').then((value) => { | |||
| expect(value.name).to.equal('test') | |||
| expect(value.data).to.deep.equal([1, 2, 3]) | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('can dig inside objects for values', function () { | |||
| let object = { | |||
| 'a': 'one', | |||
| 'b': 'two' | |||
| } | |||
| return this.robot.datastore.set('key', object).then(() => { | |||
| return this.robot.datastore.getObject('key', 'a').then((value) => { | |||
| expect(value).to.equal('one') | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('can set individual keys inside objects', function () { | |||
| let object = { | |||
| 'a': 'one', | |||
| 'b': 'two' | |||
| } | |||
| return this.robot.datastore.set('object', object).then(() => { | |||
| return this.robot.datastore.setObject('object', 'c', 'three').then(() => { | |||
| return this.robot.datastore.get('object').then((value) => { | |||
| expect(value.a).to.equal('one') | |||
| expect(value.b).to.equal('two') | |||
| expect(value.c).to.equal('three') | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('creates an object from scratch when none exists', function () { | |||
| return this.robot.datastore.setObject('object', 'key', 'value').then(() => { | |||
| return this.robot.datastore.get('object').then((value) => { | |||
| let expected = {'key': 'value'} | |||
| expect(value).to.deep.equal(expected) | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('can append to an existing array', function () { | |||
| return this.robot.datastore.set('array', [1, 2, 3]).then(() => { | |||
| return this.robot.datastore.setArray('array', 4).then(() => { | |||
| return this.robot.datastore.get('array').then((value) => { | |||
| expect(value).to.deep.equal([1, 2, 3, 4]) | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
|
|
|||
| it('creates an array from scratch when none exists', function () { | |||
| return this.robot.datastore.setArray('array', 4).then(() => { | |||
| return this.robot.datastore.get('array').then((value) => { | |||
| expect(value).to.deep.equal([4]) | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
| }) | |||
0 comments on commit
a494d0e