client: Add ObservableMap utilities and tests

This commit is contained in:
Andras Slemmer 2016-09-26 18:20:34 +01:00
parent a26908e83b
commit 99e758e021
12 changed files with 428 additions and 29 deletions

View File

@ -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<A, E, K : Any>(
class AggregatedList<A, E : Any, K : Any>(
list: ObservableList<out E>,
val toKey: (E) -> K,
val assemble: (K, ObservableList<E>) -> A
@ -42,6 +46,7 @@ class AggregatedList<A, E, K : Any>(
private class AggregationGroup<E, out A>(
val keyHashCode: Int,
val value: A,
// Invariant: sorted by E.hashCode()
val elements: ObservableList<E>
)
@ -102,7 +107,15 @@ class AggregatedList<A, E, K : Any>(
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<A, E, K : Any>(
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<E>()
observableGroupElements.add(addedItem)
@ -122,11 +135,21 @@ class AggregatedList<A, E, K : Any>(
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
}
}

View File

@ -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<K, out A, B>(
val sourceList: ObservableList<out A>,
toKey: (A) -> K,
assemble: (K, A) -> B
) : ReadOnlyBackedObservableMapBase<K, B, Unit>() {
init {
sourceList.forEach {
val key = toKey(it)
backingMap.set(key, Pair(assemble(key, it), Unit))
}
sourceList.addListener { change: ListChangeListener.Change<out A> ->
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<K, B>()
val addedMap = HashMap<K, B>()
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)))
}
}
}
}
}
}

View File

@ -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<K, A> private constructor(
sourceMap: ObservableMap<K, A>,
private val backingList: ObservableList<Map.Entry<K, A>>, // sorted by K.hashCode()
private val exposedList: ObservableList<A>
) : ObservableList<A> by exposedList {
companion object {
fun <K, A> create(sourceMap: ObservableMap<K, A>): MapValuesList<K, A> {
val backingList = FXCollections.observableArrayList<Map.Entry<K, A>>(sourceMap.entries.sortedBy { it.key!!.hashCode() })
return MapValuesList(sourceMap, backingList, backingList.map { it.value })
}
}
init {
sourceMap.addListener { change: MapChangeListener.Change<out K, out A> ->
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<K, A> {
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<K, A> {
override val key = change.key
override val value = change.valueAdded
})
}
}
}
}

View File

@ -36,3 +36,12 @@ fun <A, B, C> Observable<A>.foldToObservableList(
}
return result
}
/**
* This variant simply exposes all events in the list, in order of arrival.
*/
fun <A> Observable<A>.foldToObservableList(): ObservableList<A> {
return foldToObservableList(Unit) { newElement, _unit, list ->
list.add(newElement)
}
}

View File

@ -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 <K, V> ObservableMap<K, V>.getObservableValue(key: K): ObservableValue<V?> {
val property = SimpleObjectProperty(get(key))
addListener { change: MapChangeListener.Change<out K, out V> ->
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
}

View File

@ -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 <A> ObservableList<out ObservableValue<out A>>.flatten(): ObservableList<A>
* val heights: ObservableList<Long> = people.map(Person::height).sequence()
*/
fun <A> List<ObservableValue<out A>>.sequence(): ObservableList<A> = FlattenedList(FXCollections.observableArrayList(this))
/**
* val people: ObservableList<Person> = (..)
* val nameToHeight: ObservableMap<String, Long> = people.associateBy(Person::name) { name, person -> person.height }
*/
fun <K, A, B> ObservableList<out A>.associateBy(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap<K, B> {
return AssociatedList(this, toKey, assemble)
}
/**
* val people: ObservableList<Person> = (..)
* val nameToPerson: ObservableMap<String, Person> = people.associateBy(Person::name)
*/
fun <K, A> ObservableList<out A>.associateBy(toKey: (A) -> K): ObservableMap<K, A> {
return associateBy(toKey) { key, value -> value }
}
/**
* val people: ObservableList<Person> = (..)
* val heightToNames: ObservableMap<Long, ObservableList<String>> = people.associateByAggregation(Person::height) { name, person -> person.name }
*/
fun <K : Any, A : Any, B> ObservableList<out A>.associateByAggregation(toKey: (A) -> K, assemble: (K, A) -> B): ObservableMap<K, ObservableList<B>> {
return AssociatedList(AggregatedList(this, toKey) { key, members -> Pair(key, members) }, { it.first }) { key, pair ->
pair.second.map { assemble(key, it) }
}
}
/**
* val people: ObservableList<Person> = (..)
* val heightToPeople: ObservableMap<Long, ObservableList<Person>> = people.associateByAggregation(Person::height)
*/
fun <K : Any, A : Any> ObservableList<out A>.associateByAggregation(toKey: (A) -> K): ObservableMap<K, ObservableList<A>> {
return associateByAggregation(toKey) { key, value -> value }
}
/**
* val nameToPerson: ObservableMap<String, Person> = (..)
* val john: ObservableValue<Person?> = nameToPerson.getObservableValue("John")
*/
fun <K, V> ObservableMap<K, V>.getObservableValue(key: K): ObservableValue<V?> {
val property = SimpleObjectProperty(get(key))
addListener { change: MapChangeListener.Change<out K, out V> ->
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
}

View File

@ -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<K, A, B> : ObservableMap<K, A> {
protected val backingMap = HashMap<K, Pair<A, B>>()
private var mapListenerHelper: MapListenerHelper<K, A>? = null
protected fun fireChange(change: MapChangeListener.Change<out K, out A>) {
MapListenerHelper.fireValueChangedEvent(mapListenerHelper, change)
}
override fun addListener(listener: InvalidationListener) {
mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener)
}
override fun addListener(listener: MapChangeListener<in K, in A>?) {
mapListenerHelper = MapListenerHelper.addListener(mapListenerHelper, listener)
}
override fun removeListener(listener: InvalidationListener?) {
mapListenerHelper = MapListenerHelper.removeListener(mapListenerHelper, listener)
}
override fun removeListener(listener: MapChangeListener<in K, in A>?) {
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<MutableMap.MutableEntry<K, A>> get() = backingMap.entries.fold(mutableSetOf()) { set, entry ->
set.add(object : MutableMap.MutableEntry<K, A> {
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<K> get() = backingMap.keys
override val values: MutableCollection<A> 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<out K, A>) {
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 <A, K> ObservableMap<K, A>.createMapChange(key: K, removedValue: A?, addedValue: A?): MapChangeListener.Change<K, A> {
return object : MapChangeListener.Change<K, A>(this) {
override fun getKey() = key
override fun wasRemoved() = removedValue != null
override fun wasAdded() = addedValue != null
override fun getValueRemoved() = removedValue
override fun getValueAdded() = addedValue
}
}

View File

@ -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}")
}
}
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,13 @@
package com.r3corda.client.fxutils
import org.junit.Before
class MapValuesListTest {
@Before
fun setup() {
}
}

View File

@ -0,0 +1,21 @@
package com.r3corda.client.fxutils
import javafx.collections.MapChangeListener
import javafx.collections.ObservableMap
class ReplayedMap<K, A>(sourceMap: ObservableMap<K, A>) : ReadOnlyBackedObservableMapBase<K, A, Unit>() {
init {
sourceMap.forEach {
backingMap.set(it.key, Pair(it.value, Unit))
}
sourceMap.addListener { change: MapChangeListener.Change<out K, out A> ->
if (change.wasRemoved()) {
require(backingMap.remove(change.key)!!.first == change.valueRemoved)
}
if (change.wasAdded()) {
backingMap.set(change.key, Pair(change.valueAdded, Unit))
}
fireChange(change)
}
}
}

View File

@ -154,7 +154,7 @@ class TransactionViewer: View() {
is PartiallyResolvedTransaction.InputResolution.Unresolved -> null
is PartiallyResolvedTransaction.InputResolution.Resolved -> resolution.stateAndRef
}
}.fold(listOf()) { inputs: List<StateAndRef<ContractState>>?, state: StateAndRef<ContractState>? ->
}.foldObservable(listOf()) { inputs: List<StateAndRef<ContractState>>?, state: StateAndRef<ContractState>? ->
if (inputs != null && state != null) {
inputs + state
} else {