From 99e758e02115f3b19c6466854f21545909a07fe5 Mon Sep 17 00:00:00 2001 From: Andras Slemmer Date: Mon, 26 Sep 2016 18:20:34 +0100 Subject: [PATCH] client: Add ObservableMap utilities and tests --- .../r3corda/client/fxutils/AggregatedList.kt | 35 +++++-- .../r3corda/client/fxutils/AssociatedList.kt | 58 +++++++++++ .../r3corda/client/fxutils/MapValuesList.kt | 56 +++++++++++ .../r3corda/client/fxutils/ObservableFold.kt | 9 ++ .../client/fxutils/ObservableMapBindings.kt | 22 ----- .../client/fxutils/ObservableUtilities.kt | 57 +++++++++++ .../ReadOnlyBackedObservableMapBase.kt | 95 +++++++++++++++++++ .../client/fxutils/AggregatedListTest.kt | 16 ++++ .../client/fxutils/AssociatedListTest.kt | 73 ++++++++++++++ .../client/fxutils/MapValuesListTest.kt | 13 +++ .../com/r3corda/client/fxutils/ReplayedMap.kt | 21 ++++ .../explorer/views/TransactionViewer.kt | 2 +- 12 files changed, 428 insertions(+), 29 deletions(-) create mode 100644 client/src/main/kotlin/com/r3corda/client/fxutils/AssociatedList.kt create mode 100644 client/src/main/kotlin/com/r3corda/client/fxutils/MapValuesList.kt delete mode 100644 client/src/main/kotlin/com/r3corda/client/fxutils/ObservableMapBindings.kt create mode 100644 client/src/main/kotlin/com/r3corda/client/fxutils/ReadOnlyBackedObservableMapBase.kt create mode 100644 client/src/test/kotlin/com/r3corda/client/fxutils/AssociatedListTest.kt create mode 100644 client/src/test/kotlin/com/r3corda/client/fxutils/MapValuesListTest.kt create mode 100644 client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedMap.kt diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/AggregatedList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/AggregatedList.kt index 6d2fc6e7c8..1e817cf78b 100644 --- a/client/src/main/kotlin/com/r3corda/client/fxutils/AggregatedList.kt +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/AggregatedList.kt @@ -15,6 +15,10 @@ import kotlin.comparisons.compareValues * adding/deleting aggregations as expected. * * The ordering of the exposed list is based on the [hashCode] of keys. + * The ordering of the groups themselves is based on the [hashCode] of elements. + * + * Warning: If there are two elements [E] in the source list that have the same [hashCode] then it is not deterministic + * which one will be removed if one is removed from the source list! * * Example: * val statesGroupedByCurrency = AggregatedList(states, { state -> state.currency }) { currency, group -> @@ -33,7 +37,7 @@ import kotlin.comparisons.compareValues * @param toKey Function to extract the key from an element. * @param assemble Function to assemble the aggregation into the exposed [A]. */ -class AggregatedList( +class AggregatedList( list: ObservableList, val toKey: (E) -> K, val assemble: (K, ObservableList) -> A @@ -42,6 +46,7 @@ class AggregatedList( private class AggregationGroup( val keyHashCode: Int, val value: A, + // Invariant: sorted by E.hashCode() val elements: ObservableList ) @@ -102,7 +107,15 @@ class AggregatedList( if (aggregationGroup.elements.size == 1) { return Pair(index, aggregationList.removeAt(index)) } - aggregationGroup.elements.remove(removedItem) + val elementHashCode = removedItem.hashCode() + val removeIndex = aggregationGroup.elements.binarySearch( + comparison = { element -> compareValues(elementHashCode, element.hashCode()) } + ) + if (removeIndex < 0) { + throw IllegalStateException("Cannot find removed element $removedItem in group") + } else { + aggregationGroup.elements.removeAt(removeIndex) + } } return null } @@ -110,10 +123,10 @@ class AggregatedList( private fun addItem(addedItem: E): Int? { val key = toKey(addedItem) val keyHashCode = key.hashCode() - val index = aggregationList.binarySearch( + val aggregationIndex = aggregationList.binarySearch( comparison = { group -> compareValues(keyHashCode, group.keyHashCode.hashCode()) } ) - if (index < 0) { + if (aggregationIndex < 0) { // New aggregation val observableGroupElements = FXCollections.observableArrayList() observableGroupElements.add(addedItem) @@ -122,11 +135,21 @@ class AggregatedList( value = assemble(key, observableGroupElements), elements = observableGroupElements ) - val insertIndex = -index - 1 + val insertIndex = -aggregationIndex - 1 aggregationList.add(insertIndex, aggregationGroup) return insertIndex } else { - aggregationList[index].elements.add(addedItem) + val elements = aggregationList[aggregationIndex].elements + val elementIndex = elements.binarySearch( + comparison = { element -> compareValues(keyHashCode, element.hashCode()) } + ) + val addIndex = if (elementIndex < 0) { + -elementIndex - 1 + } else { + // There is an existing element with the same hash (which is fine) + elementIndex + } + elements.add(addIndex, addedItem) return null } } diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/AssociatedList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/AssociatedList.kt new file mode 100644 index 0000000000..9d368ed40d --- /dev/null +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/AssociatedList.kt @@ -0,0 +1,58 @@ +package com.r3corda.client.fxutils + +import javafx.collections.ListChangeListener +import javafx.collections.ObservableList +import javafx.collections.ObservableMap +import java.util.* + +/** + * [AssociatedList] creates an [ObservableMap] from an [ObservableList] by associating each list element with a unique key. + * It is *not* allowed to have several elements map to the same value! + * + * @param sourceList The source list. + * @param toKey Function returning the key. + * @param assemble The function to assemble the final map element from the list element and the associated key. + */ +class AssociatedList( + val sourceList: ObservableList, + toKey: (A) -> K, + assemble: (K, A) -> B +) : ReadOnlyBackedObservableMapBase() { + init { + sourceList.forEach { + val key = toKey(it) + backingMap.set(key, Pair(assemble(key, it), Unit)) + } + sourceList.addListener { change: ListChangeListener.Change -> + while (change.next()) { + if (change.wasPermutated()) { + } else if (change.wasUpdated()) { + } else { + val removedSourceMap = change.removed.associateBy(toKey) + val addedSourceMap = change.addedSubList.associateBy(toKey) + val removedMap = HashMap() + val addedMap = HashMap() + removedSourceMap.forEach { + val removed = backingMap.remove(it.key)?.first + removed ?: throw IllegalStateException("Removed list does not associate") + removedMap.put(it.key, removed) + } + addedSourceMap.forEach { + val oldValue = backingMap.get(it.key) + val newValue = if (oldValue == null) { + assemble(it.key, it.value) + } else { + throw IllegalStateException("Several elements associated with same key") + } + backingMap.put(it.key, Pair(newValue, Unit)) + addedMap.put(it.key, newValue) + } + val keys = removedMap.keys + addedMap.keys + keys.forEach { key -> + fireChange(createMapChange(key, removedMap.get(key), addedMap.get(key))) + } + } + } + } + } +} diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/MapValuesList.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/MapValuesList.kt new file mode 100644 index 0000000000..86b1a87b4c --- /dev/null +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/MapValuesList.kt @@ -0,0 +1,56 @@ +package com.r3corda.client.fxutils + +import javafx.collections.FXCollections +import javafx.collections.MapChangeListener +import javafx.collections.ObservableList +import javafx.collections.ObservableMap +import kotlin.comparisons.compareValues + +/** + * [MapValuesList] takes an [ObservableMap] and returns its values as an [ObservableList]. + * The order of returned elements is deterministic but unspecified. + */ +class MapValuesList private constructor( + sourceMap: ObservableMap, + private val backingList: ObservableList>, // sorted by K.hashCode() + private val exposedList: ObservableList +) : ObservableList by exposedList { + + companion object { + fun create(sourceMap: ObservableMap): MapValuesList { + val backingList = FXCollections.observableArrayList>(sourceMap.entries.sortedBy { it.key!!.hashCode() }) + return MapValuesList(sourceMap, backingList, backingList.map { it.value }) + } + } + + init { + sourceMap.addListener { change: MapChangeListener.Change -> + val keyHashCode = change.key!!.hashCode() + if (change.wasRemoved()) { + val removeIndex = backingList.binarySearch( + comparison = { entry -> compareValues(keyHashCode, entry.key!!.hashCode()) } + ) + if (removeIndex < 0) { + throw IllegalStateException("Removed value does not map") + } + if (change.wasAdded()) { + backingList[removeIndex] = object : Map.Entry { + override val key = change.key + override val value = change.valueAdded + } + } else { + backingList.removeAt(removeIndex) + } + } else if (change.wasAdded()) { + val index = backingList.binarySearch( + comparison = { entry -> compareValues(keyHashCode, entry.key!!.hashCode()) } + ) + val addIndex = -index - 1 + backingList.add(addIndex, object : Map.Entry { + override val key = change.key + override val value = change.valueAdded + }) + } + } + } +} diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableFold.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableFold.kt index 755c85b89d..9a2e35e757 100644 --- a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableFold.kt +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableFold.kt @@ -36,3 +36,12 @@ fun Observable.foldToObservableList( } return result } + +/** + * This variant simply exposes all events in the list, in order of arrival. + */ +fun Observable.foldToObservableList(): ObservableList { + return foldToObservableList(Unit) { newElement, _unit, list -> + list.add(newElement) + } +} diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableMapBindings.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableMapBindings.kt deleted file mode 100644 index 8c4cbd09d0..0000000000 --- a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableMapBindings.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.r3corda.client.fxutils - -import javafx.beans.property.SimpleObjectProperty -import javafx.beans.value.ObservableValue -import javafx.collections.MapChangeListener -import javafx.collections.ObservableMap - - -fun ObservableMap.getObservableValue(key: K): ObservableValue { - val property = SimpleObjectProperty(get(key)) - addListener { change: MapChangeListener.Change -> - if (change.key == key) { - // This is true both when a fresh element was inserted and when an existing was updated - if (change.wasAdded()) { - property.set(change.valueAdded) - } else if (change.wasRemoved()) { - property.set(null) - } - } - } - return property -} diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt index a89ed0dca1..c16d0641bb 100644 --- a/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ObservableUtilities.kt @@ -2,9 +2,12 @@ package com.r3corda.client.fxutils import javafx.beans.binding.Bindings import javafx.beans.property.ReadOnlyObjectWrapper +import javafx.beans.property.SimpleObjectProperty import javafx.beans.value.ObservableValue import javafx.collections.FXCollections +import javafx.collections.MapChangeListener import javafx.collections.ObservableList +import javafx.collections.ObservableMap import javafx.collections.transformation.FilteredList import org.fxmisc.easybind.EasyBind import java.util.function.Predicate @@ -114,3 +117,57 @@ fun ObservableList>.flatten(): ObservableList * val heights: ObservableList = people.map(Person::height).sequence() */ fun List>.sequence(): ObservableList = FlattenedList(FXCollections.observableArrayList(this)) + +/** + * val people: ObservableList = (..) + * val nameToHeight: ObservableMap = people.associateBy(Person::name) { name, person -> person.height } + */ +fun ObservableList.associateBy(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap { + return AssociatedList(this, toKey, assemble) +} + +/** + * val people: ObservableList = (..) + * val nameToPerson: ObservableMap = people.associateBy(Person::name) + */ +fun ObservableList.associateBy(toKey: (A) -> K): ObservableMap { + return associateBy(toKey) { key, value -> value } +} + +/** + * val people: ObservableList = (..) + * val heightToNames: ObservableMap> = people.associateByAggregation(Person::height) { name, person -> person.name } + */ +fun ObservableList.associateByAggregation(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap> { + return AssociatedList(AggregatedList(this, toKey) { key, members -> Pair(key, members) }, { it.first }) { key, pair -> + pair.second.map { assemble(key, it) } + } +} + +/** + * val people: ObservableList = (..) + * val heightToPeople: ObservableMap> = people.associateByAggregation(Person::height) + */ +fun ObservableList.associateByAggregation(toKey: (A) -> K): ObservableMap> { + return associateByAggregation(toKey) { key, value -> value } +} + +/** + * val nameToPerson: ObservableMap = (..) + * val john: ObservableValue = nameToPerson.getObservableValue("John") + */ +fun ObservableMap.getObservableValue(key: K): ObservableValue { + val property = SimpleObjectProperty(get(key)) + addListener { change: MapChangeListener.Change -> + if (change.key == key) { + // This is true both when a fresh element was inserted and when an existing was updated + if (change.wasAdded()) { + property.set(change.valueAdded) + } else if (change.wasRemoved()) { + property.set(null) + } + } + } + return property +} + diff --git a/client/src/main/kotlin/com/r3corda/client/fxutils/ReadOnlyBackedObservableMapBase.kt b/client/src/main/kotlin/com/r3corda/client/fxutils/ReadOnlyBackedObservableMapBase.kt new file mode 100644 index 0000000000..6e7983d4a8 --- /dev/null +++ b/client/src/main/kotlin/com/r3corda/client/fxutils/ReadOnlyBackedObservableMapBase.kt @@ -0,0 +1,95 @@ +package com.r3corda.client.fxutils + +import com.sun.javafx.collections.MapListenerHelper +import javafx.beans.InvalidationListener +import javafx.collections.MapChangeListener +import javafx.collections.ObservableMap +import java.util.* + +/** + * [ReadOnlyBackedObservableMapBase] is a base class implementing all abstract functions required for an [ObservableMap] + * using a backing HashMap that subclasses should modify. + * + * Non-read-only API calls throw. + * + * @param K The key type. + * @param A The exposed map element type. + * @param B Auxiliary data subclasses may wish to store in the backing map. + */ +open class ReadOnlyBackedObservableMapBase : ObservableMap { + protected val backingMap = HashMap>() + private var mapListenerHelper: MapListenerHelper? = null + + protected fun fireChange(change: MapChangeListener.Change) { + MapListenerHelper.fireValueChangedEvent(mapListenerHelper, change) + } + + override fun addListener(listener: InvalidationListener) { + mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener) + } + + override fun addListener(listener: MapChangeListener?) { + mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener) + } + + override fun removeListener(listener: InvalidationListener?) { + mapListenerHelper = MapListenerHelper.removeListener(mapListenerHelper, listener) + } + + override fun removeListener(listener: MapChangeListener?) { + mapListenerHelper = MapListenerHelper.removeListener(mapListenerHelper, listener) + } + + override val size: Int get() = backingMap.size + + override fun containsKey(key: K) = backingMap.containsKey(key) + + override fun containsValue(value: A) = backingMap.any { it.value.first == value } + + override fun get(key: K) = backingMap.get(key)?.first + + override fun isEmpty() = backingMap.isEmpty() + + override val entries: MutableSet> get() = backingMap.entries.fold(mutableSetOf()) { set, entry -> + set.add(object : MutableMap.MutableEntry { + override var value: A = entry.value.first + override val key = entry.key + override fun setValue(newValue: A): A { + val old = value + value = newValue + return old + } + }) + set + } + override val keys: MutableSet get() = backingMap.keys + override val values: MutableCollection get() = ArrayList(backingMap.values.map { it.first }) + + override fun clear() { + throw UnsupportedOperationException("clear() can't be called on ReadOnlyObservableMapBase") + } + + override fun put(key: K, value: A): A { + throw UnsupportedOperationException("put() can't be called on ReadOnlyObservableMapBase") + } + + override fun putAll(from: Map) { + throw UnsupportedOperationException("putAll() can't be called on ReadOnlyObservableMapBase") + } + + override fun remove(key: K): A { + throw UnsupportedOperationException("remove() can't be called on ReadOnlyObservableMapBase") + } + +} + +fun ObservableMap.createMapChange(key: K, removedValue: A?, addedValue: A?): MapChangeListener.Change { + return object : MapChangeListener.Change(this) { + override fun getKey() = key + override fun wasRemoved() = removedValue != null + override fun wasAdded() = addedValue != null + override fun getValueRemoved() = removedValue + override fun getValueAdded() = addedValue + } +} + diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt index 2f24118ad0..2e705a657a 100644 --- a/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/AggregatedListTest.kt @@ -79,6 +79,22 @@ class AggregatedListTest { sourceList.removeAll(0, 1) require(replayedList.size == 0) } + + @Test + fun multipleElementsWithSameHashWorks() { + sourceList.addAll(0, 0) + require(replayedList.size == 1) + replayedList.forEach { + when (it.first) { + 0 -> { + require(it.second.size == 2) + require(it.second[0] == 0) + require(it.second[1] == 0) + } + else -> fail("No aggregation expected with key ${it.first}") + } + } + } } diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/AssociatedListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/AssociatedListTest.kt new file mode 100644 index 0000000000..4c3e166694 --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/AssociatedListTest.kt @@ -0,0 +1,73 @@ +package com.r3corda.client.fxutils + +import javafx.collections.FXCollections +import org.junit.Before +import org.junit.Test + +class AssociatedListTest { + + var sourceList = FXCollections.observableArrayList(0) + var associatedList = AssociatedList(sourceList, { it % 3 }) { mod3, number -> number } + var replayedMap = ReplayedMap(associatedList) + + @Before + fun setup() { + sourceList = FXCollections.observableArrayList(0) + associatedList = AssociatedList(sourceList, { it % 3 }) { mod3, number -> number } + replayedMap = ReplayedMap(associatedList) + } + + @Test + fun addWorks() { + require(replayedMap.size == 1) + require(replayedMap[0] == 0) + + sourceList.add(2) + require(replayedMap.size == 2) + require(replayedMap[0] == 0) + require(replayedMap[2] == 2) + + sourceList.add(0, 4) + require(replayedMap.size == 3) + require(replayedMap[0] == 0) + require(replayedMap[2] == 2) + require(replayedMap[1] == 4) + } + + @Test + fun removeWorks() { + sourceList.addAll(2, 4) + require(replayedMap.size == 3) + + sourceList.removeAt(0) + require(replayedMap.size == 2) + require(replayedMap[2] == 2) + require(replayedMap[1] == 4) + + sourceList.add(1, 12) + require(replayedMap.size == 3) + require(replayedMap[2] == 2) + require(replayedMap[1] == 4) + require(replayedMap[0] == 12) + + sourceList.clear() + require(replayedMap.size == 0) + } + + @Test + fun updateWorks() { + sourceList.addAll(2, 4) + require(replayedMap.size == 3) + + sourceList[1] = 5 + require(replayedMap.size == 3) + require(replayedMap[0] == 0) + require(replayedMap[2] == 5) + require(replayedMap[1] == 4) + + sourceList.removeAt(1) + require(replayedMap.size == 2) + require(replayedMap[0] == 0) + require(replayedMap[1] == 4) + } +} diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/MapValuesListTest.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/MapValuesListTest.kt new file mode 100644 index 0000000000..32fa7f616a --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/MapValuesListTest.kt @@ -0,0 +1,13 @@ +package com.r3corda.client.fxutils + +import org.junit.Before + +class MapValuesListTest { + + + + @Before + fun setup() { + } + +} diff --git a/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedMap.kt b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedMap.kt new file mode 100644 index 0000000000..4696e88467 --- /dev/null +++ b/client/src/test/kotlin/com/r3corda/client/fxutils/ReplayedMap.kt @@ -0,0 +1,21 @@ +package com.r3corda.client.fxutils + +import javafx.collections.MapChangeListener +import javafx.collections.ObservableMap + +class ReplayedMap(sourceMap: ObservableMap) : ReadOnlyBackedObservableMapBase() { + init { + sourceMap.forEach { + backingMap.set(it.key, Pair(it.value, Unit)) + } + sourceMap.addListener { change: MapChangeListener.Change -> + if (change.wasRemoved()) { + require(backingMap.remove(change.key)!!.first == change.valueRemoved) + } + if (change.wasAdded()) { + backingMap.set(change.key, Pair(change.valueAdded, Unit)) + } + fireChange(change) + } + } +} diff --git a/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt index 18c3cc1b25..f0374c85cc 100644 --- a/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt +++ b/explorer/src/main/kotlin/com/r3corda/explorer/views/TransactionViewer.kt @@ -154,7 +154,7 @@ class TransactionViewer: View() { is PartiallyResolvedTransaction.InputResolution.Unresolved -> null is PartiallyResolvedTransaction.InputResolution.Resolved -> resolution.stateAndRef } - }.fold(listOf()) { inputs: List>?, state: StateAndRef? -> + }.foldObservable(listOf()) { inputs: List>?, state: StateAndRef? -> if (inputs != null && state != null) { inputs + state } else {