Resolve collisions in composite collections

Before this commit, creating a CompositeMap from two maps with the same
key has strange results, such as entrySet returning duplicate entries
with the same key.

After this commit, we give precedence to the first map by filtering out
all entries in the second map that are also mapped by the first map.

See gh-32245
This commit is contained in:
Arjen Poutsma 2024-05-02 10:48:48 +02:00
parent d5664ba01a
commit 3897ea78bb
11 changed files with 717 additions and 1 deletions

View File

@ -515,6 +515,10 @@ public abstract class CollectionUtils {
* Return a (partially unmodifiable) map that combines the provided two
* maps. Invoking {@link Map#put(Object, Object)} or {@link Map#putAll(Map)}
* on the returned map results in an {@link UnsupportedOperationException}.
*
* <p>In the case of a key collision, {@code first} takes precedence over
* {@code second}. In other words, entries in {@code second} with a key
* that is also mapped by {@code first} are effectively ignored.
* @param first the first map to compose
* @param second the second map to compose
* @return a new map that composes the given two maps
@ -531,6 +535,10 @@ public abstract class CollectionUtils {
* {@link UnsupportedOperationException} {@code putFunction} is
* {@code null}. The same applies to {@link Map#putAll(Map)} and
* {@code putAllFunction}.
*
* <p>In the case of a key collision, {@code first} takes precedence over
* {@code second}. In other words, entries in {@code second} with a key
* that is also mapped by {@code first} are effectively ignored.
* @param first the first map to compose
* @param second the second map to compose
* @param putFunction applied when {@code Map::put} is invoked. If

View File

@ -58,7 +58,7 @@ final class CompositeMap<K, V> implements Map<K, V> {
Assert.notNull(first, "First must not be null");
Assert.notNull(second, "Second must not be null");
this.first = first;
this.second = second;
this.second = new FilteredMap<>(second, key -> !this.first.containsKey(key));
this.putFunction = putFunction;
this.putAllFunction = putAllFunction;
}

View File

@ -0,0 +1,90 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.AbstractCollection;
import java.util.Collection;
import java.util.Iterator;
import java.util.function.Predicate;
/**
* Collection that filters out values that do not match a predicate.
* This type is used by {@link CompositeMap}.
* @author Arjen Poutsma
* @since 6.2
* @param <E> the type of elements maintained by this collection
*/
class FilteredCollection<E> extends AbstractCollection<E> {
private final Collection<E> delegate;
private final Predicate<E> filter;
public FilteredCollection(Collection<E> delegate, Predicate<E> filter) {
Assert.notNull(delegate, "Delegate must not be null");
Assert.notNull(filter, "Filter must not be null");
this.delegate = delegate;
this.filter = filter;
}
@Override
public int size() {
int size = 0;
for (E e : this.delegate) {
if (this.filter.test(e)) {
size++;
}
}
return size;
}
@Override
public Iterator<E> iterator() {
return new FilteredIterator<>(this.delegate.iterator(), this.filter);
}
@Override
public boolean add(E e) {
boolean added = this.delegate.add(e);
return added && this.filter.test(e);
}
@Override
@SuppressWarnings("unchecked")
public boolean remove(Object o) {
boolean removed = this.delegate.remove(o);
return removed && this.filter.test((E) o);
}
@Override
@SuppressWarnings("unchecked")
public boolean contains(Object o) {
if (this.delegate.contains(o)) {
return this.filter.test((E) o);
}
else {
return false;
}
}
@Override
public void clear() {
this.delegate.clear();
}
}

View File

@ -0,0 +1,86 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.function.Predicate;
import org.springframework.lang.Nullable;
/**
* Iterator that filters out values that do not match a predicate.
* This type is used by {@link CompositeMap}.
* @author Arjen Poutsma
* @since 6.2
* @param <E> the type of elements returned by this iterator
*/
final class FilteredIterator<E> implements Iterator<E> {
private final Iterator<E> delegate;
private final Predicate<E> filter;
@Nullable
private E next;
private boolean nextSet;
public FilteredIterator(Iterator<E> delegate, Predicate<E> filter) {
Assert.notNull(delegate, "Delegate must not be null");
Assert.notNull(filter, "Filter must not be null");
this.delegate = delegate;
this.filter = filter;
}
@Override
public boolean hasNext() {
if (this.nextSet) {
return true;
}
else {
return setNext();
}
}
@Override
public E next() {
if (!this.nextSet) {
if (!setNext()) {
throw new NoSuchElementException();
}
}
this.nextSet = false;
Assert.state(this.next != null, "Next should not be null");
return this.next;
}
private boolean setNext() {
while (this.delegate.hasNext()) {
E next = this.delegate.next();
if (this.filter.test(next)) {
this.next = next;
this.nextSet = true;
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,124 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.AbstractMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import org.springframework.lang.Nullable;
/**
* Map that filters out values that do not match a predicate.
* This type is used by {@link CompositeMap}.
* @author Arjen Poutsma
* @since 6.2
* @param <K> the type of keys maintained by this map
* @param <V> the type of mapped values
*/
final class FilteredMap<K, V> extends AbstractMap<K, V> {
private final Map<K, V> delegate;
private final Predicate<K> filter;
public FilteredMap(Map<K, V> delegate, Predicate<K> filter) {
Assert.notNull(delegate, "Delegate must not be null");
Assert.notNull(filter, "Filter must not be null");
this.delegate = delegate;
this.filter = filter;
}
@Override
public Set<Entry<K, V>> entrySet() {
return new FilteredSet<>(this.delegate.entrySet(), entry -> this.filter.test(entry.getKey()));
}
@Override
public int size() {
int size = 0;
for (K k : keySet()) {
if (this.filter.test(k)) {
size++;
}
}
return size;
}
@Override
@SuppressWarnings("unchecked")
public boolean containsKey(Object key) {
if (this.delegate.containsKey(key)) {
return this.filter.test((K) key);
}
else {
return false;
}
}
@Override
@SuppressWarnings("unchecked")
@Nullable
public V get(Object key) {
V value = this.delegate.get(key);
if (value != null && this.filter.test((K) key)) {
return value;
}
else {
return null;
}
}
@Override
@Nullable
public V put(K key, V value) {
V oldValue = this.delegate.put(key, value);
if (oldValue != null && this.filter.test(key)) {
return oldValue;
}
else {
return null;
}
}
@Override
@SuppressWarnings("unchecked")
@Nullable
public V remove(Object key) {
V oldValue = this.delegate.remove(key);
if (oldValue != null && this.filter.test((K) key)) {
return oldValue;
}
else {
return null;
}
}
@Override
public void clear() {
this.delegate.clear();
}
@Override
public Set<K> keySet() {
return new FilteredSet<>(this.delegate.keySet(), this.filter);
}
}

View File

@ -0,0 +1,66 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.Set;
import java.util.function.Predicate;
/**
* Set that filters out values that do not match a predicate.
* This type is used by {@link CompositeMap}.
* @author Arjen Poutsma
* @since 6.2
* @param <E> the type of elements maintained by this set
*/
final class FilteredSet<E> extends FilteredCollection<E> implements Set<E> {
public FilteredSet(Set<E> delegate, Predicate<E> filter) {
super(delegate, filter);
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
else if (obj instanceof Set<?> set) {
if (set.size() != size()) {
return false;
}
try {
return containsAll(set);
}
catch (ClassCastException | NullPointerException ignored) {
return false;
}
}
else {
return false;
}
}
@Override
public int hashCode() {
int hashCode = 0;
for (E obj : this) {
if (obj != null) {
hashCode += obj.hashCode();
}
}
return hashCode;
}
}

View File

@ -23,6 +23,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@ -218,4 +219,85 @@ class CompositeMapTests {
Set<Map.Entry<String, String>> entries = composite.entrySet();
assertThat(entries).containsExactly(entry("foo", "bar"), entry("baz", "qux"));
}
@Nested
class CollisionTests {
@Test
void size() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
assertThat(composite).hasSize(3);
}
@Test
void containsValue() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
assertThat(composite.containsValue("bar")).isTrue();
assertThat(composite.containsValue("qux")).isTrue();
assertThat(composite.containsValue("quux")).isFalse();
assertThat(composite.containsValue("grault")).isTrue();
}
@Test
void get() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
assertThat(composite.get("foo")).isEqualTo("bar");
assertThat(composite.get("baz")).isEqualTo("qux");
assertThat(composite.get("corge")).isEqualTo("grault");
}
@Test
void remove() {
Map<String, String> first = new HashMap<>(Map.of("foo", "bar", "baz", "qux"));
Map<String, String> second = new HashMap<>(Map.of("baz", "quux", "corge", "grault"));
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
assertThat(composite.remove("baz")).isEqualTo("qux");
assertThat(composite.containsKey("baz")).isFalse();
assertThat(first).containsExactly(entry("foo", "bar"));
assertThat(second).containsExactly(entry("corge", "grault"));
}
@Test
void keySet() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
Set<String> keySet = composite.keySet();
assertThat(keySet).containsExactlyInAnyOrder("foo", "baz", "corge");
}
@Test
void values() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
Collection<String> values = composite.values();
assertThat(values).containsExactlyInAnyOrder("bar", "qux", "grault");
}
@Test
void entrySet() {
Map<String, String> first = Map.of("foo", "bar", "baz", "qux");
Map<String, String> second = Map.of("baz", "quux", "corge", "grault");
CompositeMap<String, String> composite = new CompositeMap<>(first, second);
Set<Map.Entry<String, String>> entries = composite.entrySet();
assertThat(entries).containsExactlyInAnyOrder(entry("foo", "bar"), entry("baz", "qux"), entry("corge", "grault"));
}
}
}

View File

@ -0,0 +1,77 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Arjen Poutsma
*/
class FilteredCollectionTests {
@Test
void size() {
List<String> list = List.of("foo", "bar", "baz");
FilteredCollection<String> filtered = new FilteredCollection<>(list, s -> !s.equals("bar"));
assertThat(filtered).hasSize(2);
}
@Test
void iterator() {
List<String> list = List.of("foo", "bar", "baz");
FilteredCollection<String> filtered = new FilteredCollection<>(list, s -> !s.equals("bar"));
assertThat(filtered.iterator()).toIterable().containsExactly("foo", "baz");
}
@Test
void add() {
List<String> list = new ArrayList<>(List.of("foo"));
FilteredCollection<String> filtered = new FilteredCollection<>(list, s -> !s.equals("bar"));
boolean added = filtered.add("bar");
assertThat(added).isFalse();
assertThat(filtered).containsExactly("foo");
assertThat(list).containsExactly("foo", "bar");
}
@Test
void remove() {
List<String> list = new ArrayList<>(List.of("foo", "bar"));
FilteredCollection<String> filtered = new FilteredCollection<>(list, s -> !s.equals("bar"));
assertThat(list).contains("bar");
assertThat(filtered).doesNotContain("bar");
boolean removed = filtered.remove("bar");
assertThat(removed).isFalse();
assertThat(filtered).doesNotContain("bar");
assertThat(list).doesNotContain("bar");
}
@Test
void contains() {
List<String> list = List.of("foo", "bar", "baz");
FilteredCollection<String> filtered = new FilteredCollection<>(list, s -> !s.equals("bar"));
boolean contained = filtered.contains("bar");
assertThat(contained).isFalse();
}
}

View File

@ -0,0 +1,38 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.List;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Arjen Poutsma
*/
final class FilteredIteratorTests {
@Test
void filter() {
List<String> list = List.of("foo", "bar", "baz");
FilteredIterator<String> filtered = new FilteredIterator<>(list.iterator(), s -> !s.equals("bar"));
assertThat(filtered).toIterable().containsExactly("foo", "baz");
}
}

View File

@ -0,0 +1,103 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static java.util.Map.entry;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Arjen Poutsma
*/
class FilteredMapTests {
@Test
void size() {
Map<String, String> map = Map.of("foo", "bar", "baz", "qux", "quux", "corge");
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
assertThat(filtered).hasSize(2);
}
@Test
void entrySet() {
Map<String, String> map = Map.of("foo", "bar", "baz", "qux", "quux", "corge");
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
assertThat(filtered.entrySet()).containsExactlyInAnyOrder(entry("foo", "bar"), entry("quux", "corge"));
}
@Test
void containsKey() {
Map<String, String> map = Map.of("foo", "bar", "baz", "qux", "quux", "corge");
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
boolean contained = filtered.containsKey("baz");
assertThat(contained).isFalse();
}
@Test
void get() {
Map<String, String> map = Map.of("foo", "bar", "baz", "qux", "quux", "corge");
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
String value = filtered.get("baz");
assertThat(value).isNull();
}
@Test
void put() {
Map<String, String> map = new HashMap<>(Map.of("foo", "bar", "quux", "corge"));
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
String value = filtered.put("baz", "qux");
assertThat(value).isNull();
assertThat(filtered.containsKey("baz")).isFalse();
assertThat(map.get("baz")).isEqualTo("qux");
// overwrite
value = filtered.put("baz", "QUX");
assertThat(value).isNull();
assertThat(filtered.containsKey("baz")).isFalse();
assertThat(map.get("baz")).isEqualTo("QUX");
}
@Test
void remove() {
Map<String, String> map = new HashMap<>(Map.of("foo", "bar", "baz", "qux", "quux", "corge"));
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
String value = filtered.remove("baz");
assertThat(value).isNull();
assertThat(filtered.containsKey("baz")).isFalse();
assertThat(map.containsKey("baz")).isFalse();
}
@Test
void keySet() {
Map<String, String> map = Map.of("foo", "bar", "baz", "qux", "quux", "corge");
FilteredMap<String, String> filtered = new FilteredMap<>(map, s -> !s.equals("baz"));
Set<String> keySet = filtered.keySet();
assertThat(keySet).containsExactlyInAnyOrder("foo", "quux");
}
}

View File

@ -0,0 +1,42 @@
/*
* Copyright 2002-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.util;
import java.util.Collections;
import java.util.Set;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @author Arjen Poutsma
*/
class FilteredSetTests {
@Test
void testEquals() {
Set<String> set = Set.of("foo", "bar", "baz");
FilteredSet<String> filtered = new FilteredSet<>(set, s -> !s.equals("bar"));
Set<String> expected = Set.of("foo", "baz");
assertThat(filtered.equals(expected)).isTrue();
assertThat(filtered.equals(set)).isFalse();
assertThat(filtered.equals(Collections.emptySet())).isFalse();
}
}