Skip to content

Commit 0979d2f

Browse files
authored
Add StreamUtils utility class. (#105)
* Add StreamUtils utility class. - streamNullable to safely stream any iterable, even null. - last(n) to collect the last n elements from a stream. Signed-off-by: Sjoerd Talsma <sjoerdtalsma@users.noreply.github.com> * Add caveat in javadoc. Signed-off-by: Sjoerd Talsma <sjoerdtalsma@users.noreply.github.com> * Add unittest for the utility-class unsupported constructor. Signed-off-by: Sjoerd Talsma <sjoerdtalsma@users.noreply.github.com> * Suppress and explain sonar warning. Signed-off-by: Sjoerd Talsma <sjoerdtalsma@users.noreply.github.com> --------- Signed-off-by: Sjoerd Talsma <sjoerdtalsma@users.noreply.github.com>
1 parent 9526ea4 commit 0979d2f

File tree

2 files changed

+289
-0
lines changed

2 files changed

+289
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2022-2025 Talsma ICT
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package nl.talsmasoftware.misc.utils;
17+
18+
import java.util.ArrayList;
19+
import java.util.Collection;
20+
import java.util.Collections;
21+
import java.util.List;
22+
import java.util.Objects;
23+
import java.util.stream.Collector;
24+
import java.util.stream.Stream;
25+
import java.util.stream.StreamSupport;
26+
27+
public final class StreamUtils {
28+
29+
private StreamUtils() {
30+
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated.");
31+
}
32+
33+
/**
34+
* Stream the elements of a (nullable) {@linkplain Iterable} object.
35+
*
36+
* <p>
37+
* The resulting stream also filters any {@code null} elements from the result.
38+
*
39+
* @param iterable The iterable object (optional, may be {@code null}).
40+
* @param <T> The type of the iterable elements.
41+
* @return Non-{@code null} Stream with the elements from the given iterable.
42+
* Null-values will be filtered out.
43+
*/
44+
public static <T> Stream<T> streamNullable(Iterable<T> iterable) {
45+
if (iterable == null) {
46+
return Stream.empty();
47+
} else if (iterable instanceof Collection) {
48+
return ((Collection<T>) iterable).stream().filter(Objects::nonNull);
49+
}
50+
return StreamSupport.stream(iterable.spliterator(), false).filter(Objects::nonNull);
51+
}
52+
53+
/**
54+
* Collector for the last <em>n</em> elements of a stream.
55+
*
56+
* <p>
57+
* <b>Note:</b> Obvious caveat: the stream to be collected must be a limited stream,
58+
* e.g. have an actual last element.
59+
*
60+
* <p>
61+
* The returned list is unmodifiable.
62+
*
63+
* @param maxSize The maximum size of the list to return.
64+
* @param <T> The type of elements to be collected.
65+
* @return Collector returning the last <em>n</em> elements as a list,
66+
* or the full stream content if there were at most {@code maxSize} elements.
67+
*/
68+
public static <T> Collector<T, ArrayList<T>, List<T>> last(final int maxSize) {
69+
if (maxSize <= 0) {
70+
throw new IllegalArgumentException("Maximum size must be a positive number.");
71+
}
72+
return Collector.of(() -> new ArrayList<>(maxSize),
73+
(accumulator, value) -> {
74+
if (accumulator.size() < maxSize) {
75+
accumulator.add(value);
76+
} else {
77+
Collections.rotate(accumulator, -1);
78+
accumulator.set(maxSize - 1, value);
79+
}
80+
},
81+
(acc1, acc2) -> {
82+
if (acc2.size() < maxSize) {
83+
int skip = acc1.size() + acc2.size() - maxSize;
84+
if (skip <= 0) {
85+
acc2.addAll(0, acc1);
86+
} else {
87+
acc2.addAll(0, acc1.subList(skip, acc1.size()));
88+
}
89+
}
90+
return acc2;
91+
},
92+
Collections::unmodifiableList);
93+
}
94+
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
* Copyright 2022-2025 Talsma ICT
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package nl.talsmasoftware.misc.utils;
17+
18+
import org.junit.jupiter.api.Test;
19+
20+
import java.lang.reflect.Constructor;
21+
import java.lang.reflect.InvocationTargetException;
22+
import java.util.ArrayList;
23+
import java.util.Arrays;
24+
import java.util.Collections;
25+
import java.util.List;
26+
import java.util.stream.Collector;
27+
import java.util.stream.IntStream;
28+
import java.util.stream.Stream;
29+
30+
import static java.util.stream.Collectors.toCollection;
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
33+
34+
class StreamUtilsTest {
35+
@Test
36+
@SuppressWarnings({
37+
"java:S1874" // We use java8, where canAccess is not yet available to replace isAccessible.
38+
})
39+
void streamUtils_utility_class_has_unsupported_constructor() throws ReflectiveOperationException {
40+
Constructor<StreamUtils> constructor = StreamUtils.class.getDeclaredConstructor();
41+
assertThat(constructor.isAccessible()).isFalse();
42+
constructor.setAccessible(true);
43+
assertThatThrownBy(constructor::newInstance)
44+
.isInstanceOf(InvocationTargetException.class)
45+
.cause()
46+
.isInstanceOf(UnsupportedOperationException.class)
47+
.hasMessage("This is a utility class and cannot be instantiated.");
48+
}
49+
50+
@Test
51+
void streamNullable_null_returns_empty_stream() {
52+
// when
53+
Stream<?> result = StreamUtils.streamNullable(null);
54+
55+
// then
56+
assertThat(result).isNotNull().isEmpty();
57+
}
58+
59+
@Test
60+
void streamNullable_empty_returns_empty_stream() {
61+
// when
62+
Stream<?> result = StreamUtils.streamNullable(Collections.emptyList());
63+
64+
// then
65+
assertThat(result).isNotNull().isEmpty();
66+
}
67+
68+
@Test
69+
void streamNullable_empty_Iterable_returns_empty_stream() {
70+
// given
71+
Iterable<?> emptyIterable = Collections::emptyIterator;
72+
73+
// when
74+
Stream<?> result = StreamUtils.streamNullable(emptyIterable);
75+
76+
// then
77+
assertThat(result).isNotNull().isEmpty();
78+
}
79+
80+
@Test
81+
void streamNullable_returns_stream_from_collection() {
82+
// when
83+
Stream<Integer> result = StreamUtils.streamNullable(Arrays.asList(1, null, 2, null, 3));
84+
85+
// then
86+
assertThat(result).isNotNull().isNotEmpty().hasSize(3).containsExactly(1, 2, 3);
87+
}
88+
89+
@Test
90+
void streamNullable_returns_stream_from_iterable() {
91+
// given
92+
Iterable<Integer> iterable = Arrays.asList(1, null, 2, null, 3)::iterator;
93+
94+
// when
95+
Stream<Integer> result = StreamUtils.streamNullable(iterable);
96+
97+
// then
98+
assertThat(result).isNotNull().isNotEmpty().hasSize(3).containsExactly(1, 2, 3);
99+
}
100+
101+
@Test
102+
void last_count_must_be_positive() {
103+
assertThatThrownBy(() -> StreamUtils.last(0))
104+
.isInstanceOf(IllegalArgumentException.class)
105+
.hasMessage("Maximum size must be a positive number.");
106+
}
107+
108+
@Test
109+
void last_2_of_10() {
110+
// given
111+
Stream<String> stream = IntStream.range(1, 11).mapToObj(Integer::toString);
112+
113+
// when
114+
List<String> result = stream.collect(StreamUtils.last(2));
115+
116+
// then
117+
assertThat(result).hasSize(2).containsExactly("9", "10");
118+
}
119+
120+
@Test
121+
void last_10_of_2() {
122+
// given
123+
Stream<String> stream = Stream.of("1", "2");
124+
125+
// when
126+
List<String> result = stream.collect(StreamUtils.last(10));
127+
128+
// then
129+
assertThat(result).hasSize(2).containsExactly("1", "2");
130+
}
131+
132+
@Test
133+
void last_10_of_10() {
134+
// given
135+
Stream<String> stream = IntStream.range(1, 11).mapToObj(Integer::toString);
136+
137+
// when
138+
List<String> result = stream.collect(StreamUtils.last(10));
139+
140+
// then
141+
assertThat(result).hasSize(10).containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9", "10");
142+
}
143+
144+
@Test
145+
void last_combiner_acc2_is_full() {
146+
// given
147+
Collector<String, ArrayList<String>, List<String>> collector = StreamUtils.last(10);
148+
ArrayList<String> acc1 = IntStream.range(1, 11).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
149+
ArrayList<String> acc2 = IntStream.range(11, 21).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
150+
151+
// when
152+
List<String> result = collector.combiner().apply(acc1, acc2);
153+
154+
// then
155+
assertThat(result)
156+
.hasSize(10)
157+
.containsExactly("11", "12", "13", "14", "15", "16", "17", "18", "19", "20")
158+
.isSameAs(acc2);
159+
}
160+
161+
@Test
162+
void last_combiner_acc2_has_some_space() {
163+
// given
164+
Collector<String, ArrayList<String>, List<String>> collector = StreamUtils.last(10);
165+
ArrayList<String> acc1 = IntStream.range(1, 11).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
166+
ArrayList<String> acc2 = IntStream.range(11, 16).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
167+
168+
// when
169+
List<String> result = collector.combiner().apply(acc1, acc2);
170+
171+
// then
172+
assertThat(result)
173+
.hasSize(10)
174+
.containsExactly("6", "7", "8", "9", "10", "11", "12", "13", "14", "15")
175+
.isSameAs(acc2);
176+
}
177+
178+
@Test
179+
void last_combiner_acc1_and_acc2_both_fit() {
180+
// given
181+
Collector<String, ArrayList<String>, List<String>> collector = StreamUtils.last(10);
182+
ArrayList<String> acc1 = IntStream.range(1, 5).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
183+
ArrayList<String> acc2 = IntStream.range(5, 11).mapToObj(Integer::toString).collect(toCollection(ArrayList::new));
184+
185+
// when
186+
List<String> result = collector.combiner().apply(acc1, acc2);
187+
188+
// then
189+
assertThat(result)
190+
.hasSize(10)
191+
.containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
192+
.isSameAs(acc2);
193+
}
194+
}

0 commit comments

Comments
 (0)