From f326230b17f37631f0e7702802b06ea7ce20c4e2 Mon Sep 17 00:00:00 2001 From: b-ma Date: Tue, 30 Apr 2024 13:05:13 +0200 Subject: [PATCH] feat: implement filter for state collections --- src/common/BaseSharedState.js | 9 +- src/common/BaseSharedStateCollection.js | 53 +++++- src/common/BaseStateManager.js | 62 +++++-- tests/states/SharedState.spec.js | 25 +++ tests/states/StateCollection.spec.js | 218 ++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 23 deletions(-) diff --git a/src/common/BaseSharedState.js b/src/common/BaseSharedState.js index c431311c..7d875cbf 100644 --- a/src/common/BaseSharedState.js +++ b/src/common/BaseSharedState.js @@ -492,8 +492,9 @@ ${JSON.stringify(initValues, null, 2)}`); } /** - * Get all the key / value pairs of the state. If a parameter is of `any` - * type, a deep copy is made. + * Get all the key / value pairs of the state. + * + * If a parameter is of `any` type, a deep copy is made. * * @return {object} * @example @@ -514,8 +515,8 @@ ${JSON.stringify(initValues, null, 2)}`); } /** - * Get all the key / value pairs of the state. If a parameter is of `any` - * type, a deep copy is made. + * Get all the key / value pairs of the state. + * * Similar to `getValues` but returns a reference to the underlying value in * case of `any` type. May be usefull if the underlying value is big (e.g. * sensors recordings, etc.) and deep cloning expensive. Be aware that if diff --git a/src/common/BaseSharedStateCollection.js b/src/common/BaseSharedStateCollection.js index cbf4ceeb..524735a6 100644 --- a/src/common/BaseSharedStateCollection.js +++ b/src/common/BaseSharedStateCollection.js @@ -1,8 +1,9 @@ /** @private */ class BaseSharedStateCollection { - constructor(stateManager, schemaName, options = {}) { + constructor(stateManager, schemaName, filter = null, options = {}) { this._stateManager = stateManager; this._schemaName = schemaName; + this._filter = filter; this._options = Object.assign({ excludeLocal: false }, options); this._schema = null; @@ -18,8 +19,19 @@ class BaseSharedStateCollection { async _init() { this._schema = await this._stateManager.getSchema(this._schemaName); + // if filter is set, check that it contains only valid param names + if (this._filter !== null) { + const keys = Object.keys(this._schema); + + for (let filter of this._filter) { + if (!keys.includes(filter)) { + throw new ReferenceError(`[SharedStateCollection] Invalid filter key (${filter}) for schema "${this._schemaName}"`) + } + } + } + this._unobserve = await this._stateManager.observe(this._schemaName, async (schemaName, stateId) => { - const state = await this._stateManager.attach(schemaName, stateId); + const state = await this._stateManager.attach(schemaName, stateId, this._filter); this._states.push(state); state.onDetach(() => { @@ -108,6 +120,21 @@ class BaseSharedStateCollection { return this._states.map(state => state.getValues()); } + /** + * Return the current values of all the states in the collection. + * + * Similar to `getValues` but returns a reference to the underlying value in + * case of `any` type. May be usefull if the underlying value is big (e.g. + * sensors recordings, etc.) and deep cloning expensive. Be aware that if + * changes are made on the returned object, the state of your application will + * become inconsistent. + * + * @return {Object[]} + */ + getValuesUnsafe() { + return this._states.map(state => state.getValues()); + } + /** * Return the current param value of all the states in the collection. * @@ -115,6 +142,24 @@ class BaseSharedStateCollection { * @return {any[]} */ get(name) { + // we can delegate to the state.get(name) method for throwing in case of filtered + // keys, as the Promise.all will reject on first reject Promise + return this._states.map(state => state.get(name)); + } + + /** + * Similar to `get` but returns a reference to the underlying value in case of + * `any` type. May be usefull if the underlying value is big (e.g. sensors + * recordings, etc.) and deep cloning expensive. Be aware that if changes are + * made on the returned object, the state of your application will become + * inconsistent. + * + * @param {String} name - Name of the parameter + * @return {any[]} + */ + getUnsafe(name) { + // we can delegate to the state.get(name) method for throwing in case of filtered + // keys, as the Promise.all will reject on first reject Promise return this._states.map(state => state.get(name)); } @@ -126,8 +171,8 @@ class BaseSharedStateCollection { * current call and will be passed as third argument to all update listeners. */ async set(updates, context = null) { - // hot fix for https://github.com/collective-soundworks/soundworks/issues/85 - // to be cleaned soon + // we can delegate to the state.set(update) method for throwing in case of + // filtered keys, as the Promise.all will reject on first reject Promise const promises = this._states.map(state => state.set(updates, context)); return Promise.all(promises); } diff --git a/src/common/BaseStateManager.js b/src/common/BaseStateManager.js index 1e334726..7fa0a382 100644 --- a/src/common/BaseStateManager.js +++ b/src/common/BaseStateManager.js @@ -251,26 +251,29 @@ class BaseStateManager { } if (arguments.length === 2) { - if (Number.isFinite(stateIdOrFilter)) { + if (stateIdOrFilter === null) { + stateId = null; + filter = null; + } else if (Number.isFinite(stateIdOrFilter)) { stateId = stateIdOrFilter; filter = null; } else if (Array.isArray(stateIdOrFilter)) { stateId = null; filter = stateIdOrFilter; } else { - throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be either a number or an array`); + throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be either null, a number or an array`); } } if (arguments.length === 3) { stateId = stateIdOrFilter; - if (!Number.isFinite(stateId)) { - throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be a number`); + if (stateId !== null && !Number.isFinite(stateId)) { + throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be either null or a number`); } - if (!Array.isArray(filter)) { - throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 2 should be a number`); + if (filter !== null && !Array.isArray(filter)) { + throw new TypeError(`Cannot execute 'attach' on 'StateManager': argument 3 should be either null or an array`); } } @@ -366,7 +369,7 @@ class BaseStateManager { options = Object.assign(defaultOptions, args[1]); } else { - throw new Error(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`); + throw new TypeError(`[stateManager] Invalid signature, refer to the StateManager.observe documentation"`); } break; @@ -431,21 +434,50 @@ class BaseStateManager { * Returns a collection of all the states created from the schema name. * * @param {string} schemaName - Name of the schema. + * @param {array|null} [filter=null] - Array of parameter names that are of interest + * for every state of the collection. No filter is apllied if set to `null` (default). * @param {object} options - Options. * @param {boolean} [options.excludeLocal = false] - If set to true, exclude states * created locallly, i.e. by the same node, from the collection. * @returns {server.SharedStateCollection|client.SharedStateCollection} */ - async getCollection(schemaName, options) { - const collection = new SharedStateCollection(this, schemaName, options); - - try { - await collection._init(); - } catch (err) { - console.log(err.message); - throw new Error(`[stateManager] Cannot create collection, schema "${schemaName}" does not exists`); + async getCollection(schemaName, filterOrOptions = null, options = {}) { + if (!isString(schemaName)) { + throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'schemaName' should be a string"`); } + let filter; + + if (arguments.length === 2) { + if (filterOrOptions === null) { + filter = null; + options = null; + } else if (Array.isArray(filterOrOptions)) { + filter = filterOrOptions; + options = {}; + } else if (typeof filterOrOptions === 'object') { + filter = null; + options = filterOrOptions; + } else { + throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': argument 2 should be either null, an array or an object"`); + } + } + + if (arguments.length === 3) { + filter = filterOrOptions; + + if (filter !== null && !Array.isArray(filter)) { + throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'filter' should be either an array or null"`); + } + + if (options === null || typeof options !== 'object') { + throw new TypeError(`[stateManager] Cannot execute 'getCollection' on 'StateManager': 'options' should be an object"`); + } + } + + const collection = new SharedStateCollection(this, schemaName, filter, options); + await collection._init(); + return collection; } } diff --git a/tests/states/SharedState.spec.js b/tests/states/SharedState.spec.js index c20d2217..ba0f3cb3 100644 --- a/tests/states/SharedState.spec.js +++ b/tests/states/SharedState.spec.js @@ -566,11 +566,36 @@ describe('# SharedState - filtered attached state', () => { it(`should support attach(schemaName, filter)`, async () => { const owned = await server.stateManager.create('filtered'); const attached = await client.stateManager.attach('filtered', ['bool', 'string']); + + assert.equal(attached.id, owned.id); }); it(`should support attach(schemaName, stateId, filter)`, async () => { const owned = await server.stateManager.create('filtered'); const attached = await client.stateManager.attach('filtered', owned.id, ['bool', 'string']); + + assert.equal(attached.id, owned.id); + }); + + it(`should support explicit default values, attach(schemaName, null)`, async () => { + const owned = await server.stateManager.create('filtered'); + const attached = await client.stateManager.attach('filtered', null); + + assert.equal(attached.id, owned.id); + }); + + it(`should support attach(schemaName, stateId, null)`, async () => { + const owned = await server.stateManager.create('filtered'); + const attached = await client.stateManager.attach('filtered', owned.id, null); + + assert.equal(attached.id, owned.id); + }); + + it(`should support explicit default values, attach(schemaName, null, null)`, async () => { + const owned = await server.stateManager.create('filtered'); + const attached = await client.stateManager.attach('filtered', null, null); + + assert.equal(attached.id, owned.id); }); it(`should throw if filter contains invalid keys`, async () => { diff --git a/tests/states/StateCollection.spec.js b/tests/states/StateCollection.spec.js index 8a76db07..07ca6956 100644 --- a/tests/states/StateCollection.spec.js +++ b/tests/states/StateCollection.spec.js @@ -471,3 +471,221 @@ describe(`# SharedStateCollection`, () => { }); }); }); + +describe('# SharedStateCollection - filtered collection', () => { + let server; + let clients = []; + + beforeEach(async () => { + // --------------------------------------------------- + // server + // --------------------------------------------------- + server = new Server(config); + server.stateManager.registerSchema('filtered', { + bool: { + type: 'boolean', + default: false, + }, + int: { + type: 'integer', + default: 0, + }, + string: { + type: 'string', + default: 'a', + }, + }); + await server.start(); + + // --------------------------------------------------- + // clients + // --------------------------------------------------- + clients[0] = new Client({ role: 'test', ...config }); + clients[1] = new Client({ role: 'test', ...config }); + clients[2] = new Client({ role: 'test', ...config }); + await clients[0].start(); + await clients[1].start(); + await clients[2].start(); + }); + + afterEach(async function() { + server.stop(); + }); + + describe(`## getCollection(schemaName, filter)`, () => { + it(`should throw if filter contains invalid keys`, async () => { + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + let errored = false; + + try { + const attached = await clients[2].stateManager.getCollection('filtered', ['invalid', 'toto']); + } catch (err) { + console.log(err.message); + errored = true; + } + + assert.isTrue(errored); + }); + + it(`should return valid collection`, async () => { + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', ['bool', 'string']); + + assert.equal(attached.size, 2); + }); + }); + + describe(`## onUpdate(callback)`, () => { + it(`should propagate only filtered keys`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + const expected = { bool: true, int: 1, string: 'b' }; + + owned1.onUpdate(updates => { + assert.deepEqual(updates, expected); + }); + + attached.onUpdate((state, updates) => { + assert.deepEqual(Object.keys(updates), filter); + }); + + await owned1.set(expected); + await delay(20); + }); + + it(`should not propagate if filtered updates is empty object`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + const expected = { int: 1 }; + let batchedResponses = 0; + let callbackExecuted = false; + + clients[2].socket.addListener(BATCHED_TRANSPORT_CHANNEL, (args) => { + batchedResponses += 1; + }); + + owned1.onUpdate(updates => { + assert.deepEqual(updates, expected); + }); + + attached.onUpdate((state, updates) => { + callbackExecuted = true; + }); + + await owned1.set(expected); + await delay(20); + + assert.isFalse(callbackExecuted); + assert.equal(batchedResponses, 0); + }); + }); + + describe(`## set(updates)`, () => { + it(`should throw early if trying to set modify a param which is not filtered`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + let onUpdateCalled = false; + let errored = false; + + owned1.onUpdate(() => onUpdateCalled = true); + + try { + await attached.set({ int: 42 }); + } catch (err) { + console.log(err.message); + errored = true; + } + + await delay(20); + + assert.isTrue(errored); + assert.isFalse(onUpdateCalled); + }); + }); + + describe(`## get(name)`, () => { + it(`should throw if trying to access a param which is not filtered`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + let errored = false; + + try { + await attached.get('int'); + } catch (err) { + console.log(err.message); + errored = true; + } + + await delay(20); + + assert.isTrue(errored); + }); + }); + + describe(`## getUnsafe(name)`, () => { + it(`should throw if trying to access a param which is not filtered`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + let errored = false; + + try { + await attached.getUnsafe('int'); + } catch (err) { + console.log(err.message); + errored = true; + } + + await delay(20); + + assert.isTrue(errored); + }); + }); + + describe(`## getValues()`, () => { + it(`should return a filtered object`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + + await owned1.set({ bool: true }); + await delay(20); + + const values = attached.getValues(); + assert.deepEqual(values, [ + { bool: true, string: 'a' }, + { bool: false, string: 'a' }, + ]); + }); + }); + + describe(`## getValuesUnsafe()`, () => { + it(`should return a filtered object`, async () => { + const filter = ['bool', 'string']; + const owned1 = await clients[0].stateManager.create('filtered'); + const owned2 = await clients[1].stateManager.create('filtered'); + const attached = await clients[2].stateManager.getCollection('filtered', filter); + + await owned1.set({ bool: true }); + await delay(20); + + const values = attached.getValuesUnsafe(); + assert.deepEqual(values, [ + { bool: true, string: 'a' }, + { bool: false, string: 'a' }, + ]); + }); + }); +});