diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index b50a524a..7d099136 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -22,27 +22,33 @@ export class JoinedMap extends BaseObservableMap { this._sources = sources; } - onAdd(key, value) { - this.emitAdd(key, value); + onAdd(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + const occludingValue = this._getValueFromOccludedSources(source, key); + if (occludingValue !== undefined) { + // adding a value that will occlude another one should + // first emit a remove + this.emitRemove(key, occludingValue); + } + this.emitAdd(key, value); + } } - onRemove(key, value) { - this.emitRemove(key, value); + onRemove(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitRemove(key, value); + const occludedValue = this._getValueFromOccludedSources(source, key); + if (occludedValue !== undefined) { + // removing a value that so far occluded another one should + // emit an add for the occluded value after the removal + this.emitAdd(key, occludedValue); + } + } } - onUpdate(key, value, params) { - this.emitUpdate(key, value, params); - } - - onSubscribeFirst() { - this._subscriptions = this._sources.map(source => source.subscribe(this)); - super.onSubscribeFirst(); - } - - onUnsubscribeLast() { - super.onUnsubscribeLast(); - for (const s of this._subscriptions) { - s(); + onUpdate(source, key, value, params) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitUpdate(key, value, params); } } @@ -50,6 +56,49 @@ export class JoinedMap extends BaseObservableMap { this.emitReset(); } + onSubscribeFirst() { + this._subscriptions = this._sources.map(source => new SourceSubscriptionHandler(source, this).subscribe()); + super.onSubscribeFirst(); + } + + _isKeyAtSourceOccluded(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = 0; i < index; i += 1) { + if (this._sources[i].get(key) !== undefined) { + return true; + } + } + return false; + } + + // get the value that the given source and key occlude, if any + _getValueFromOccludedSources(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = index + 1; i < this._sources.length; i += 1) { + const source = this._sources[i]; + const occludedValue = source.get(key); + if (occludedValue !== undefined) { + return occludedValue; + } + } + return undefined; + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + for (const s of this._subscriptions) { + s.dispose(); + } + } + [Symbol.iterator]() { return new JoinedIterator(this._sources); } @@ -74,6 +123,7 @@ class JoinedIterator { this._sources = sources; this._sourceIndex = -1; this._currentIterator = null; + this._encounteredKeys = new Set(); } next() { @@ -91,22 +141,140 @@ class JoinedIterator { this._currentIterator = null; continue; } else { - result = sourceResult; + const key = sourceResult.value[0]; + if (!this._encounteredKeys.has(key)) { + this._encounteredKeys.add(key); + result = sourceResult; + } } } return result; } } +class SourceSubscriptionHandler { + constructor(source, joinedMap) { + this._source = source; + this._joinedMap = joinedMap; + this._subscription = null; + } + + subscribe() { + this._source.subscribe(this); + return this; + } + + dispose() { + this._subscription = this._subscription(); + } + + onAdd(key, value) { + this._joinedMap.onAdd(this._source, key, value); + } + + onRemove(key, value) { + this._joinedMap.onRemove(this._source, key, value); + } + + onUpdate(key, value, params) { + this._joinedMap.onUpdate(this._source, key, value, params); + } + + onReset() { + this._joinedMap.onReset(this._source); + } +} + + +import { ObservableMap } from "./ObservableMap.js"; + export function tests() { + + function observeMap(map) { + const events = []; + map.subscribe({ + onAdd(key, value) { events.push({type: "add", key, value}); }, + onRemove(key, value) { events.push({type: "remove", key, value}); }, + onUpdate(key, value, params) { events.push({type: "update", key, value, params}); } + }); + return events; + } + return { "joined iterator": assert => { - const it = new JoinedIterator([[1, 2], [3, 4]]); - assert.equal(it.next().value, 1); - assert.equal(it.next().value, 2); - assert.equal(it.next().value, 3); - assert.equal(it.next().value, 4); + const firstKV = ["a", 1]; + const secondKV = ["b", 2]; + const thirdKV = ["c", 3]; + const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]); + assert.equal(it.next().value, firstKV); + assert.equal(it.next().value, secondKV); + assert.equal(it.next().value, thirdKV); assert.equal(it.next().done, true); + }, + "prevent key collision during iteration": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + second.add("b", 3); + first.add("a", 1); + const it = join[Symbol.iterator](); + assert.deepEqual(it.next().value, ["a", 1]); + assert.deepEqual(it.next().value, ["b", 3]); + assert.equal(it.next().done, true); + }, + "adding occluded key doesn't emit add": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + const events = observeMap(join); + first.add("a", 1); + second.add("a", 2); + assert.equal(events.length, 1); + assert.equal(events[0].type, "add"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + }, + "updating occluded key doesn't emit update": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + second.update("a", 3); + assert.equal(events.length, 0); + }, + "removal of occluding key emits add after remove": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + first.remove("a"); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 2); + }, + "adding occluding key emits remove first": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + const events = observeMap(join); + first.add("a", 1); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 2); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 1); } }; }